Add support for filtering and pagination on job output

This commit is contained in:
mabashian
2021-01-12 15:50:45 -05:00
parent 31124e07c6
commit 98da019d12
15 changed files with 12277 additions and 8973 deletions

View File

@@ -53,6 +53,16 @@ class Jobs extends RelaunchMixin(Base) {
} }
return this.http.get(endpoint, { params }); return this.http.get(endpoint, { params });
} }
readEventOptions(id, type = 'playbook') {
let endpoint;
if (type === 'playbook') {
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
} else {
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
}
return this.http.options(endpoint);
}
} }
export default Jobs; export default Jobs;

View File

@@ -13,6 +13,7 @@ import useRequest, {
useDeleteItems, useDeleteItems,
useDismissableError, useDismissableError,
} from '../../util/useRequest'; } from '../../util/useRequest';
import isJobRunning from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem'; import JobListItem from './JobListItem';
import JobListCancelButton from './JobListCancelButton'; import JobListCancelButton from './JobListCancelButton';
@@ -102,7 +103,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
useCallback(async () => { useCallback(async () => {
return Promise.all( return Promise.all(
selected.map(job => { selected.map(job => {
if (['new', 'pending', 'waiting', 'running'].includes(job.status)) { if (isJobRunning(job.status)) {
return JobsAPI.cancel(job.id, job.type); return JobsAPI.cancel(job.id, job.type);
} }
return Promise.resolve(); return Promise.resolve();

View File

@@ -4,18 +4,18 @@ import { t } from '@lingui/macro';
import { arrayOf, func } from 'prop-types'; import { arrayOf, func } from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { KebabifiedContext } from '../../contexts/Kebabified'; import { KebabifiedContext } from '../../contexts/Kebabified';
import isJobRunning from '../../util/jobs';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { Job } from '../../types'; import { Job } from '../../types';
function cannotCancelBecausePermissions(job) { function cannotCancelBecausePermissions(job) {
return ( return (
!job.summary_fields.user_capabilities.start && !job.summary_fields.user_capabilities.start && isJobRunning(job.status)
['pending', 'waiting', 'running'].includes(job.status)
); );
} }
function cannotCancelBecauseNotRunning(job) { function cannotCancelBecauseNotRunning(job) {
return !['pending', 'waiting', 'running'].includes(job.status); return !isJobRunning(job.status);
} }
function JobListCancelButton({ i18n, jobsToCancel, onCancel }) { function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {

View File

@@ -29,6 +29,7 @@ function AdvancedSearch({
onSearch, onSearch,
searchableKeys, searchableKeys,
relatedSearchableKeys, relatedSearchableKeys,
maxSelectHeight,
}) { }) {
// TODO: blocked by pf bug, eventually separate these into two groups in the select // TODO: blocked by pf bug, eventually separate these into two groups in the select
// for now, I'm spreading set to get rid of duplicate keys...when they are grouped // for now, I'm spreading set to get rid of duplicate keys...when they are grouped
@@ -91,7 +92,7 @@ function AdvancedSearch({
selections={prefixSelection} selections={prefixSelection}
isOpen={isPrefixDropdownOpen} isOpen={isPrefixDropdownOpen}
placeholderText={i18n._(t`Set type`)} placeholderText={i18n._(t`Set type`)}
maxHeight="500px" maxHeight={maxSelectHeight}
noResultsFoundText={i18n._(t`No results found`)} noResultsFoundText={i18n._(t`No results found`)}
> >
<SelectOption <SelectOption
@@ -129,7 +130,7 @@ function AdvancedSearch({
placeholderText={i18n._(t`Key`)} placeholderText={i18n._(t`Key`)}
isCreatable isCreatable
onCreateOption={setKeySelection} onCreateOption={setKeySelection}
maxHeight="500px" maxHeight={maxSelectHeight}
noResultsFoundText={i18n._(t`No results found`)} noResultsFoundText={i18n._(t`No results found`)}
> >
{allKeys.map(optionKey => ( {allKeys.map(optionKey => (
@@ -149,7 +150,7 @@ function AdvancedSearch({
selections={lookupSelection} selections={lookupSelection}
isOpen={isLookupDropdownOpen} isOpen={isLookupDropdownOpen}
placeholderText={i18n._(t`Lookup type`)} placeholderText={i18n._(t`Lookup type`)}
maxHeight="500px" maxHeight={maxSelectHeight}
noResultsFoundText={i18n._(t`No results found`)} noResultsFoundText={i18n._(t`No results found`)}
> >
<SelectOption <SelectOption
@@ -269,11 +270,13 @@ AdvancedSearch.propTypes = {
onSearch: PropTypes.func.isRequired, onSearch: PropTypes.func.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string), searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
maxSelectHeight: PropTypes.string,
}; };
AdvancedSearch.defaultProps = { AdvancedSearch.defaultProps = {
searchableKeys: [], searchableKeys: [],
relatedSearchableKeys: [], relatedSearchableKeys: [],
maxSelectHeight: '300px',
}; };
export default withI18n()(AdvancedSearch); export default withI18n()(AdvancedSearch);

View File

@@ -41,6 +41,8 @@ function Search({
searchableKeys, searchableKeys,
relatedSearchableKeys, relatedSearchableKeys,
onShowAdvancedSearch, onShowAdvancedSearch,
isDisabled,
maxSelectHeight,
}) { }) {
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
const [searchKey, setSearchKey] = useState( const [searchKey, setSearchKey] = useState(
@@ -178,6 +180,7 @@ function Search({
selections={searchColumnName} selections={searchColumnName}
isOpen={isSearchDropdownOpen} isOpen={isSearchDropdownOpen}
ouiaId="simple-key-select" ouiaId="simple-key-select"
isDisabled={isDisabled}
> >
{searchOptions} {searchOptions}
</Select> </Select>
@@ -201,6 +204,7 @@ function Search({
onSearch={onSearch} onSearch={onSearch}
searchableKeys={searchableKeys} searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys} relatedSearchableKeys={relatedSearchableKeys}
maxSelectHeight={maxSelectHeight}
/> />
)) || )) ||
(options && ( (options && (
@@ -219,6 +223,8 @@ function Search({
isOpen={isFilterDropdownOpen} isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`} placeholderText={`Filter By ${name}`}
ouiaId={`filter-by-${key}`} ouiaId={`filter-by-${key}`}
isDisabled={isDisabled}
maxHeight={maxSelectHeight}
> >
{options.map(([optionKey, optionLabel]) => ( {options.map(([optionKey, optionLabel]) => (
<SelectOption <SelectOption
@@ -241,6 +247,8 @@ function Search({
isOpen={isFilterDropdownOpen} isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`} placeholderText={`Filter By ${name}`}
ouiaId={`filter-by-${key}`} ouiaId={`filter-by-${key}`}
isDisabled={isDisabled}
maxHeight={maxSelectHeight}
> >
<SelectOption key="true" value="true"> <SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)} {booleanLabels.true || i18n._(t`Yes`)}
@@ -265,11 +273,12 @@ function Search({
value={searchValue} value={searchValue}
onChange={setSearchValue} onChange={setSearchValue}
onKeyDown={handleTextKeyDown} onKeyDown={handleTextKeyDown}
isDisabled={isDisabled}
/> />
<div css={!searchValue && `cursor:not-allowed`}> <div css={!searchValue && `cursor:not-allowed`}>
<Button <Button
variant={ButtonVariant.control} variant={ButtonVariant.control}
isDisabled={!searchValue} isDisabled={!searchValue || isDisabled}
aria-label={i18n._(t`Search submit button`)} aria-label={i18n._(t`Search submit button`)}
onClick={handleSearch} onClick={handleSearch}
> >
@@ -310,11 +319,15 @@ Search.propTypes = {
onSearch: PropTypes.func, onSearch: PropTypes.func,
onRemove: PropTypes.func, onRemove: PropTypes.func,
onShowAdvancedSearch: PropTypes.func.isRequired, onShowAdvancedSearch: PropTypes.func.isRequired,
isDisabled: PropTypes.bool,
maxSelectHeight: PropTypes.string,
}; };
Search.defaultProps = { Search.defaultProps = {
onSearch: null, onSearch: null,
onRemove: null, onRemove: null,
isDisabled: false,
maxSelectHeight: '300px',
}; };
export default withI18n()(withRouter(Search)); export default withI18n()(withRouter(Search));

View File

@@ -26,28 +26,53 @@ function Job({ i18n, setBreadcrumb }) {
const { id, type } = useParams(); const { id, type } = useParams();
const match = useRouteMatch(); const match = useRouteMatch();
const { isLoading, error, request: fetchJob, result } = useRequest( const {
isLoading,
error,
request: fetchJob,
result: { jobDetail, eventRelatedSearchableKeys, eventSearchableKeys },
} = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await JobsAPI.readDetail(id, type); const { data: jobDetailData } = await JobsAPI.readDetail(id, type);
const { data: jobEventOptions } = await JobsAPI.readEventOptions(
id,
type
);
if ( if (
data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault') jobDetailData?.summary_fields?.credentials?.find(
cred => cred.kind === 'vault'
)
) { ) {
const { const {
data: { results }, data: { results },
} = await JobsAPI.readCredentials(data.id, type); } = await JobsAPI.readCredentials(jobDetailData.id, type);
data.summary_fields.credentials = results; jobDetailData.summary_fields.credentials = results;
}
setBreadcrumb(jobDetailData);
return {
jobDetail: jobDetailData,
eventRelatedSearchableKeys: (
jobEventOptions?.related_search_fields || []
).map(val => val.slice(0, -8)),
eventSearchableKeys: Object.keys(
jobEventOptions.actions?.GET || {}
).filter(key => jobEventOptions.actions?.GET[key].filterable),
};
}, [id, type, setBreadcrumb]),
{
jobDetail: null,
eventRelatedSearchableKeys: [],
eventSearchableKeys: [],
} }
setBreadcrumb(data);
return data;
}, [id, type, setBreadcrumb])
); );
useEffect(() => { useEffect(() => {
fetchJob(); fetchJob();
}, [fetchJob]); }, [fetchJob]);
const job = useWsJob(result); const job = useWsJob(jobDetail);
const tabsArray = [ const tabsArray = [
{ {
@@ -112,7 +137,12 @@ function Job({ i18n, setBreadcrumb }) {
<JobDetail type={type} job={job} /> <JobDetail type={type} job={job} />
</Route>, </Route>,
<Route key="output" path="/jobs/:type/:id/output"> <Route key="output" path="/jobs/:type/:id/output">
<JobOutput type={type} job={job} /> <JobOutput
type={type}
job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys}
/>
</Route>, </Route>,
<Route key="not-found" path="*"> <Route key="not-found" path="*">
<ContentError isNotFound> <ContentError isNotFound>

View File

@@ -1,5 +1,5 @@
import React, { Component, Fragment } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { withRouter } from 'react-router-dom'; import { useHistory, useLocation, withRouter } from 'react-router-dom';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -10,16 +10,24 @@ import {
InfiniteLoader, InfiniteLoader,
List, List,
} from 'react-virtualized'; } from 'react-virtualized';
import { Button } from '@patternfly/react-core';
import Ansi from 'ansi-to-html'; import Ansi from 'ansi-to-html';
import hasAnsi from 'has-ansi'; import hasAnsi from 'has-ansi';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import {
Toolbar as _Toolbar,
ToolbarContent as _ToolbarContent,
ToolbarItem,
ToolbarToggleGroup,
Tooltip,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { CardBody } from '../../../components/Card'; import { CardBody as _CardBody } from '../../../components/Card';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import Search from '../../../components/Search';
import StatusIcon from '../../../components/StatusIcon'; import StatusIcon from '../../../components/StatusIcon';
import JobEvent from './JobEvent'; import JobEvent from './JobEvent';
@@ -27,6 +35,17 @@ import JobEventSkeleton from './JobEventSkeleton';
import PageControls from './PageControls'; import PageControls from './PageControls';
import HostEventModal from './HostEventModal'; import HostEventModal from './HostEventModal';
import { HostStatusBar, OutputToolbar } from './shared'; import { HostStatusBar, OutputToolbar } from './shared';
import getRowRangePageSize from './shared/jobOutputUtils';
import isJobRunning from '../../../util/jobs';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import {
encodeNonDefaultQueryString,
parseQueryString,
mergeParams,
replaceParams,
removeParams,
getQSConfig,
} from '../../../util/qs';
import { import {
JobsAPI, JobsAPI,
ProjectUpdatesAPI, ProjectUpdatesAPI,
@@ -36,6 +55,10 @@ import {
AdHocCommandsAPI, AdHocCommandsAPI,
} from '../../../api'; } from '../../../api';
const QS_CONFIG = getQSConfig('job_output', {
order_by: 'start_line',
});
const EVENT_START_TASK = 'playbook_on_task_start'; const EVENT_START_TASK = 'playbook_on_task_start';
const EVENT_START_PLAY = 'playbook_on_play_start'; const EVENT_START_PLAY = 'playbook_on_play_start';
const EVENT_STATS_PLAY = 'playbook_on_stats'; const EVENT_STATS_PLAY = 'playbook_on_stats';
@@ -136,6 +159,12 @@ function getLineTextHtml({ created, event, start_line, stdout }) {
}; };
} }
const CardBody = styled(_CardBody)`
display: flex;
flex-flow: column;
height: calc(100vh - 267px);
`;
const HeaderTitle = styled.div` const HeaderTitle = styled.div`
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -154,9 +183,9 @@ const OutputWrapper = styled.div`
background-color: #ffffff; background-color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto;
font-family: monospace; font-family: monospace;
font-size: 15px; font-size: 15px;
height: calc(100vh - 350px);
outline: 1px solid #d7d7d7; outline: 1px solid #d7d7d7;
${({ cssMap }) => ${({ cssMap }) =>
Object.keys(cssMap).map(className => `.${className}{${cssMap[className]}}`)} Object.keys(cssMap).map(className => `.${className}{${cssMap[className]}}`)}
@@ -169,6 +198,15 @@ const OutputFooter = styled.div`
flex: 1; flex: 1;
`; `;
const Toolbar = styled(_Toolbar)`
position: inherit;
`;
const ToolbarContent = styled(_ToolbarContent)`
padding-left: 0px;
padding-right: 0px;
`;
let ws; let ws;
function connectJobSocket({ type, id }, onMessage) { function connectJobSocket({ type, id }, onMessage) {
ws = new WebSocket( ws = new WebSocket(
@@ -219,194 +257,86 @@ function range(low, high) {
return numbers; return numbers;
} }
class JobOutput extends Component { function isHostEvent(jobEvent) {
constructor(props) { const { event, event_data, host, type } = jobEvent;
super(props); let isHost;
this.listRef = React.createRef(); if (typeof host === 'number' || (event_data && event_data.res)) {
this.state = { isHost = true;
contentError: null, } else if (
deletionError: null, type === 'project_update_event' &&
cancelError: null, event !== 'runner_on_skipped' &&
hasContentLoading: true, event_data.host
results: {}, ) {
currentlyLoading: [], isHost = true;
remoteRowCount: 0, } else {
isHostModalOpen: false, isHost = false;
hostEvent: {}, }
cssMap: {}, return isHost;
jobStatus: props.job.status ?? 'waiting', }
showCancelPrompt: false,
cancelInProgress: false,
};
this.cache = new CellMeasurerCache({ const cache = new CellMeasurerCache({
fixedWidth: true, fixedWidth: true,
defaultHeight: 25, defaultHeight: 25,
}); });
this._isMounted = false; function JobOutput({
this.loadJobEvents = this.loadJobEvents.bind(this); job,
this.handleDeleteJob = this.handleDeleteJob.bind(this); type,
this.handleCancelOpen = this.handleCancelOpen.bind(this); eventRelatedSearchableKeys,
this.handleCancelConfirm = this.handleCancelConfirm.bind(this); eventSearchableKeys,
this.handleCancelClose = this.handleCancelClose.bind(this); }) {
this.rowRenderer = this.rowRenderer.bind(this); const location = useLocation();
this.handleHostEventClick = this.handleHostEventClick.bind(this); const listRef = useRef(null);
this.handleHostModalClose = this.handleHostModalClose.bind(this); const isMounted = useRef(false);
this.handleScrollFirst = this.handleScrollFirst.bind(this); const previousWidth = useRef(0);
this.handleScrollLast = this.handleScrollLast.bind(this); const jobSocketCounter = useRef(0);
this.handleScrollNext = this.handleScrollNext.bind(this); const interval = useRef(null);
this.handleScrollPrevious = this.handleScrollPrevious.bind(this); const history = useHistory();
this.handleResize = this.handleResize.bind(this); const [contentError, setContentError] = useState(null);
this.isRowLoaded = this.isRowLoaded.bind(this); const [hasContentLoading, setHasContentLoading] = useState(true);
this.loadMoreRows = this.loadMoreRows.bind(this); const [results, setResults] = useState({});
this.scrollToRow = this.scrollToRow.bind(this); const [currentlyLoading, setCurrentlyLoading] = useState([]);
this.monitorJobSocketCounter = this.monitorJobSocketCounter.bind(this); const [isHostModalOpen, setIsHostModalOpen] = useState(false);
} const [hostEvent, setHostEvent] = useState({});
const [cssMap, setCssMap] = useState({});
const [remoteRowCount, setRemoteRowCount] = useState(0);
componentDidMount() { useEffect(() => {
const { job } = this.props; isMounted.current = true;
this._isMounted = true; loadJobEvents();
this.loadJobEvents();
if (job.result_traceback) return;
if (isJobRunning(job.status)) {
connectJobSocket(job, data => { connectJobSocket(job, data => {
if (data.group_name === 'job_events') { if (data.counter && data.counter > jobSocketCounter.current) {
if (data.counter && data.counter > this.jobSocketCounter) { jobSocketCounter.current = data.counter;
this.jobSocketCounter = data.counter; } else if (data.final_counter && data.unified_job_id === job.id) {
} jobSocketCounter.current = data.final_counter;
}
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
if (data.final_counter) {
this.jobSocketCounter = data.final_counter;
}
if (data.status) {
this.setState({ jobStatus: data.status });
}
} }
}); });
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000); interval.current = setInterval(() => monitorJobSocketCounter(), 5000);
} }
componentDidUpdate(prevProps, prevState) { return function cleanup() {
// recompute row heights for any job events that have transitioned
// from loading to loaded
const { currentlyLoading, cssMap } = this.state;
let shouldRecomputeRowHeights = false;
prevState.currentlyLoading
.filter(n => !currentlyLoading.includes(n))
.forEach(n => {
shouldRecomputeRowHeights = true;
this.cache.clear(n);
});
if (Object.keys(cssMap).length !== Object.keys(prevState.cssMap).length) {
shouldRecomputeRowHeights = true;
}
if (shouldRecomputeRowHeights) {
if (this.listRef.recomputeRowHeights) {
this.listRef.recomputeRowHeights();
}
}
}
componentWillUnmount() {
if (ws) { if (ws) {
ws.close(); ws.close();
} }
clearInterval(this.interval); clearInterval(interval.current);
this._isMounted = false; isMounted.current = false;
}
monitorJobSocketCounter() {
const { remoteRowCount } = this.state;
if (this.jobSocketCounter >= remoteRowCount) {
this._isMounted &&
this.setState({ remoteRowCount: this.jobSocketCounter + 1 });
}
}
async loadJobEvents() {
const { job, type } = this.props;
const loadRange = range(1, 50);
this._isMounted &&
this.setState(({ currentlyLoading }) => ({
hasContentLoading: true,
currentlyLoading: currentlyLoading.concat(loadRange),
}));
try {
const {
data: { results: newResults = [], count },
} = await JobsAPI.readEvents(job.id, type, {
page_size: 50,
order_by: 'start_line',
});
this._isMounted &&
this.setState(({ results }) => {
let countOffset = 1;
if (job?.result_traceback) {
const tracebackEvent = {
counter: 1,
created: null,
event: null,
type: null,
stdout: job?.result_traceback,
start_line: 0,
}; };
const firstIndex = newResults.findIndex( }, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
jobEvent => jobEvent.counter === 1
);
if (firstIndex && newResults[firstIndex]?.stdout) {
const stdoutLines = newResults[firstIndex].stdout.split('\r\n');
stdoutLines[0] = tracebackEvent.stdout;
newResults[firstIndex].stdout = stdoutLines.join('\r\n');
} else {
countOffset += 1;
newResults.unshift(tracebackEvent);
}
}
newResults.forEach(jobEvent => {
results[jobEvent.counter] = jobEvent;
});
return { results, remoteRowCount: count + countOffset };
});
} catch (err) {
this.setState({ contentError: err });
} finally {
this._isMounted &&
this.setState(({ currentlyLoading }) => ({
hasContentLoading: false,
currentlyLoading: currentlyLoading.filter(
n => !loadRange.includes(n)
),
}));
}
}
handleCancelOpen() { useEffect(() => {
this.setState({ showCancelPrompt: true }); if (listRef.current?.recomputeRowHeights) {
listRef.current.recomputeRowHeights();
} }
}, [currentlyLoading, cssMap, remoteRowCount]);
handleCancelClose() { const {
this.setState({ showCancelPrompt: false }); request: deleteJob,
} isLoading: isDeleteLoading,
error: deleteError,
async handleCancelConfirm() { } = useRequest(
const { job, type } = this.props; useCallback(async () => {
this.setState({ cancelInProgress: true });
try {
await JobsAPI.cancel(job.id, type);
} catch (cancelError) {
this.setState({ cancelError });
} finally {
this.setState({ showCancelPrompt: false, cancelInProgress: false });
}
}
async handleDeleteJob() {
const { job, history } = this.props;
try {
switch (job.type) { switch (job.type) {
case 'project_update': case 'project_update':
await ProjectUpdatesAPI.destroy(job.id); await ProjectUpdatesAPI.destroy(job.id);
@@ -427,63 +357,116 @@ class JobOutput extends Component {
await JobsAPI.destroy(job.id); await JobsAPI.destroy(job.id);
} }
history.push('/jobs'); history.push('/jobs');
} catch (err) { }, [job, history])
this.setState({ deletionError: err }); );
const { error, dismissError } = useDismissableError(deleteError);
const monitorJobSocketCounter = () => {
if (jobSocketCounter.current === remoteRowCount) {
clearInterval(interval.current);
}
if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
setRemoteRowCount(jobSocketCounter.current);
}
};
const loadJobEvents = async () => {
const loadRange = range(1, 50);
if (isMounted.current) {
setHasContentLoading(true);
setCurrentlyLoading(prevCurrentlyLoading =>
prevCurrentlyLoading.concat(loadRange)
);
}
try {
const {
data: { results: fetchedEvents = [], count },
} = await JobsAPI.readEvents(job.id, type, {
page: 1,
page_size: 50,
...parseQueryString(QS_CONFIG, location.search),
});
if (isMounted.current) {
let countOffset = 0;
if (job?.result_traceback) {
const tracebackEvent = {
counter: 1,
created: null,
event: null,
type: null,
stdout: job?.result_traceback,
start_line: 0,
};
const firstIndex = fetchedEvents.findIndex(
jobEvent => jobEvent.counter === 1
);
if (firstIndex && fetchedResults[firstIndex]?.stdout) {
const stdoutLines = fetchedEvents[firstIndex].stdout.split('\r\n');
stdoutLines[0] = tracebackEvent.stdout;
fetchedEvents[firstIndex].stdout = stdoutLines.join('\r\n');
} else {
countOffset += 1;
fetchedEvents.unshift(tracebackEvent);
} }
} }
isRowLoaded({ index }) { const newResults = {};
const { results, currentlyLoading } = this.state; let newResultsCssMap = {};
fetchedEvents.forEach((jobEvent, index) => {
newResults[index] = jobEvent;
const { lineCssMap } = getLineTextHtml(jobEvent);
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
});
setResults(newResults);
setRemoteRowCount(count + countOffset);
setCssMap(newResultsCssMap);
}
} catch (err) {
setContentError(err);
} finally {
if (isMounted.current) {
setHasContentLoading(false);
setCurrentlyLoading(prevCurrentlyLoading =>
prevCurrentlyLoading.filter(n => !loadRange.includes(n))
);
loadRange.forEach(n => {
cache.clear(n);
});
}
}
};
const isRowLoaded = ({ index }) => {
if (results[index]) { if (results[index]) {
return true; return true;
} }
return currentlyLoading.includes(index); return currentlyLoading.includes(index);
}
handleHostEventClick(hostEvent) {
this.setState({
isHostModalOpen: true,
hostEvent,
});
}
handleHostModalClose() {
this.setState({
isHostModalOpen: false,
});
}
rowRenderer({ index, parent, key, style }) {
const { results } = this.state;
const isHostEvent = jobEvent => {
const { event, event_data, host, type } = jobEvent;
let isHost;
if (typeof host === 'number' || (event_data && event_data.res)) {
isHost = true;
} else if (
type === 'project_update_event' &&
event !== 'runner_on_skipped' &&
event_data.host
) {
isHost = true;
} else {
isHost = false;
}
return isHost;
}; };
const handleHostEventClick = hostEventToOpen => {
setHostEvent(hostEventToOpen);
setIsHostModalOpen(true);
};
const handleHostModalClose = () => {
setIsHostModalOpen(false);
};
const rowRenderer = ({ index, parent, key, style }) => {
let actualLineTextHtml = []; let actualLineTextHtml = [];
if (results[index]) { if (results[index]) {
const { lineTextHtml, lineCssMap } = getLineTextHtml(results[index]); const { lineTextHtml } = getLineTextHtml(results[index]);
this.setState(({ cssMap }) => ({ cssMap: { ...cssMap, ...lineCssMap } }));
actualLineTextHtml = lineTextHtml; actualLineTextHtml = lineTextHtml;
} }
return ( return (
<CellMeasurer <CellMeasurer
key={key} key={key}
cache={this.cache} cache={cache}
parent={parent} parent={parent}
rowIndex={index} rowIndex={index}
columnIndex={0} columnIndex={0}
@@ -491,10 +474,11 @@ class JobOutput extends Component {
{results[index] ? ( {results[index] ? (
<JobEvent <JobEvent
isClickable={isHostEvent(results[index])} isClickable={isHostEvent(results[index])}
onJobEventClick={() => this.handleHostEventClick(results[index])} onJobEventClick={() => handleHostEventClick(results[index])}
className="row" className="row"
style={style} style={style}
lineTextHtml={actualLineTextHtml} lineTextHtml={actualLineTextHtml}
index={index}
{...results[index]} {...results[index]}
/> />
) : ( ) : (
@@ -507,92 +491,189 @@ class JobOutput extends Component {
)} )}
</CellMeasurer> </CellMeasurer>
); );
} };
loadMoreRows({ startIndex, stopIndex }) { const loadMoreRows = ({ startIndex, stopIndex }) => {
if (startIndex === 0 && stopIndex === 0) { if (startIndex === 0 && stopIndex === 0) {
return Promise.resolve(null); return Promise.resolve(null);
} }
const { job, type } = this.props;
const loadRange = range(startIndex, stopIndex); if (stopIndex > startIndex + 50) {
this._isMounted && stopIndex = startIndex + 50;
this.setState(({ currentlyLoading }) => ({ }
currentlyLoading: currentlyLoading.concat(loadRange),
})); const { page, pageSize, firstIndex } = getRowRangePageSize(
startIndex,
stopIndex
);
const loadRange = range(
firstIndex,
Math.min(firstIndex + pageSize, remoteRowCount)
);
if (isMounted.current) {
setCurrentlyLoading(prevCurrentlyLoading =>
prevCurrentlyLoading.concat(loadRange)
);
}
const params = { const params = {
counter__gte: startIndex, page,
counter__lte: stopIndex, page_size: pageSize,
order_by: 'start_line', ...parseQueryString(QS_CONFIG, location.search),
}; };
return JobsAPI.readEvents(job.id, type, params).then(response => { return JobsAPI.readEvents(job.id, type, params).then(response => {
this._isMounted && if (isMounted.current) {
this.setState(({ results, currentlyLoading }) => { const newResults = {};
response.data.results.forEach(jobEvent => { let newResultsCssMap = {};
results[jobEvent.counter] = jobEvent; response.data.results.forEach((jobEvent, index) => {
newResults[firstIndex + index] = jobEvent;
const { lineCssMap } = getLineTextHtml(jobEvent);
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
});
setResults(prevResults => ({
...prevResults,
...newResults,
}));
setCssMap(prevCssMap => ({
...prevCssMap,
...newResultsCssMap,
}));
setCurrentlyLoading(prevCurrentlyLoading =>
prevCurrentlyLoading.filter(n => !loadRange.includes(n))
);
loadRange.forEach(n => {
cache.clear(n);
});
}
}); });
return {
results,
currentlyLoading: currentlyLoading.filter(
n => !loadRange.includes(n)
),
}; };
});
});
}
scrollToRow(rowIndex) { const scrollToRow = rowIndex => {
this.listRef.scrollToRow(rowIndex); listRef.current.scrollToRow(rowIndex);
} };
handleScrollPrevious() { const handleScrollPrevious = () => {
const startIndex = this.listRef.Grid._renderedRowStartIndex; const startIndex = listRef.current.Grid._renderedRowStartIndex;
const stopIndex = this.listRef.Grid._renderedRowStopIndex; const stopIndex = listRef.current.Grid._renderedRowStopIndex;
const scrollRange = stopIndex - startIndex + 1; const scrollRange = stopIndex - startIndex + 1;
this.scrollToRow(Math.max(0, startIndex - scrollRange)); scrollToRow(Math.max(0, startIndex - scrollRange));
} };
handleScrollNext() { const handleScrollNext = () => {
const stopIndex = this.listRef.Grid._renderedRowStopIndex; const stopIndex = listRef.current.Grid._renderedRowStopIndex;
this.scrollToRow(stopIndex - 1); scrollToRow(stopIndex - 1);
} };
handleScrollFirst() { const handleScrollFirst = () => {
this.scrollToRow(0); scrollToRow(0);
} };
handleScrollLast() { const handleScrollLast = () => {
const { remoteRowCount } = this.state; scrollToRow(remoteRowCount);
this.scrollToRow(remoteRowCount - 1); };
}
handleResize({ width }) { const handleResize = ({ width }) => {
if (width !== this._previousWidth) { if (width !== previousWidth) {
this.cache.clearAll(); cache.clearAll();
if (this.listRef?.recomputeRowHeights) { if (listRef.current?.recomputeRowHeights) {
this.listRef.recomputeRowHeights(); listRef.current.recomputeRowHeights();
} }
} }
this._previousWidth = width; previousWidth.current = width;
} };
render() { const handleSearch = (key, value) => {
const { job } = this.props; let params = parseQueryString(QS_CONFIG, location.search);
params = mergeParams(params, { [key]: value });
pushHistoryState(params);
};
const { const handleReplaceSearch = (key, value) => {
contentError, const oldParams = parseQueryString(QS_CONFIG, location.search);
deletionError, pushHistoryState(replaceParams(oldParams, { [key]: value }));
hasContentLoading, };
hostEvent,
isHostModalOpen, const handleRemoveSearchTerm = (key, value) => {
remoteRowCount, let oldParams = parseQueryString(QS_CONFIG, location.search);
cssMap, if (parseInt(value, 10)) {
jobStatus, oldParams = removeParams(QS_CONFIG, oldParams, {
showCancelPrompt, [key]: parseInt(value, 10),
cancelError, });
cancelInProgress, }
} = this.state; pushHistoryState(removeParams(QS_CONFIG, oldParams, { [key]: value }));
};
const handleRemoveAllSearchTerms = () => {
const oldParams = parseQueryString(QS_CONFIG, location.search);
pushHistoryState(removeParams(QS_CONFIG, oldParams, { ...oldParams }));
};
const pushHistoryState = params => {
const { pathname } = history.location;
const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, params);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
};
const renderSearchComponent = i18n => (
<Search
qsConfig={QS_CONFIG}
columns={[
{
name: i18n._(t`Stdout`),
key: 'stdout__icontains',
isDefault: true,
},
{
name: i18n._(t`Event`),
key: 'event',
options: [
['runner_on_failed', i18n._(t`Host Failed`)],
['runner_on_start', i18n._(t`Host Started`)],
['runner_on_ok', i18n._(t`Host OK`)],
['runner_on_error', i18n._(t`Host Failure`)],
['runner_on_skipped', i18n._(t`Host Skipped`)],
['runner_on_unreachable', i18n._(t`Host Unreachable`)],
['runner_on_no_hosts', i18n._(t`No Hosts Remaining`)],
['runner_on_async_poll', i18n._(t`Host Polling`)],
['runner_on_async_ok', i18n._(t`Host Async OK`)],
['runner_on_async_failed', i18n._(t`Host Async Failure`)],
['runner_item_on_ok', i18n._(t`Item OK`)],
['runner_item_on_failed', i18n._(t`Item Failed`)],
['runner_item_on_skipped', i18n._(t`Item Skipped`)],
['runner_retry', i18n._(t`Host Retry`)],
['runner_on_file_diff', i18n._(t`File Difference`)],
['playbook_on_start', i18n._(t`Playbook Started`)],
['playbook_on_notify', i18n._(t`Running Handlers`)],
['playbook_on_include', i18n._(t`Including File`)],
['playbook_on_no_hosts_matched', i18n._(t`No Hosts Matched`)],
['playbook_on_no_hosts_remaining', i18n._(t`No Hosts Remaining`)],
['playbook_on_task_start', i18n._(t`Task Started`)],
['playbook_on_vars_prompt', i18n._(t`Variables Prompted`)],
['playbook_on_setup', i18n._(t`Gathering Facts`)],
['playbook_on_play_start', i18n._(t`Play Started`)],
['playbook_on_stats', i18n._(t`Playbook Complete`)],
['debug', i18n._(t`Debug`)],
['verbose', i18n._(t`Verbose`)],
['deprecated', i18n._(t`Deprecated`)],
['warning', i18n._(t`Warning`)],
['system_warning', i18n._(t`System Warning`)],
['error', i18n._(t`Error`)],
],
},
{ name: i18n._(t`Advanced`), key: 'advanced' },
]}
searchableKeys={eventSearchableKeys}
relatedSearchableKeys={eventRelatedSearchableKeys}
onSearch={handleSearch}
onReplaceSearch={handleReplaceSearch}
onShowAdvancedSearch={() => {}}
onRemove={handleRemoveSearchTerm}
isDisabled={isJobRunning(job.status)}
/>
);
if (hasContentLoading) { if (hasContentLoading) {
return <ContentLoading />; return <ContentLoading />;
@@ -603,11 +684,13 @@ class JobOutput extends Component {
} }
return ( return (
<Fragment> <I18n>
{({ i18n }) => (
<>
<CardBody> <CardBody>
{isHostModalOpen && ( {isHostModalOpen && (
<HostEventModal <HostEventModal
onClose={this.handleHostModalClose} onClose={handleHostModalClose}
isOpen={isHostModalOpen} isOpen={isHostModalOpen}
hostEvent={hostEvent} hostEvent={hostEvent}
/> />
@@ -619,39 +702,62 @@ class JobOutput extends Component {
</HeaderTitle> </HeaderTitle>
<OutputToolbar <OutputToolbar
job={job} job={job}
jobStatus={jobStatus} onDelete={deleteJob}
onDelete={this.handleDeleteJob} isDeleteDisabled={isDeleteLoading}
onCancel={this.handleCancelOpen}
/> />
</OutputHeader> </OutputHeader>
<HostStatusBar counts={job.host_status_counts} /> <HostStatusBar counts={job.host_status_counts} />
<Toolbar
id="job_output-toolbar"
clearAllFilters={handleRemoveAllSearchTerms}
collapseListedFiltersBreakpoint="lg"
clearFiltersButtonText={i18n._(t`Clear all filters`)}
>
<ToolbarContent>
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
<ToolbarItem variant="search-filter">
{isJobRunning(job.status) ? (
<Tooltip
content={i18n._(
t`Search is disabled while the job is running`
)}
>
{renderSearchComponent(i18n)}
</Tooltip>
) : (
renderSearchComponent(i18n)
)}
</ToolbarItem>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
<PageControls <PageControls
onScrollFirst={this.handleScrollFirst} onScrollFirst={handleScrollFirst}
onScrollLast={this.handleScrollLast} onScrollLast={handleScrollLast}
onScrollNext={this.handleScrollNext} onScrollNext={handleScrollNext}
onScrollPrevious={this.handleScrollPrevious} onScrollPrevious={handleScrollPrevious}
/> />
<OutputWrapper cssMap={cssMap}> <OutputWrapper cssMap={cssMap}>
<InfiniteLoader <InfiniteLoader
isRowLoaded={this.isRowLoaded} isRowLoaded={isRowLoaded}
loadMoreRows={this.loadMoreRows} loadMoreRows={loadMoreRows}
rowCount={remoteRowCount} rowCount={remoteRowCount}
> >
{({ onRowsRendered, registerChild }) => ( {({ onRowsRendered, registerChild }) => (
<AutoSizer nonce={window.NONCE_ID} onResize={this.handleResize}> <AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
{({ width, height }) => { {({ width, height }) => {
return ( return (
<List <List
ref={ref => { ref={ref => {
this.listRef = ref;
registerChild(ref); registerChild(ref);
listRef.current = ref;
}} }}
deferredMeasurementCache={this.cache} deferredMeasurementCache={cache}
height={height || 1} height={height || 1}
onRowsRendered={onRowsRendered} onRowsRendered={onRowsRendered}
rowCount={remoteRowCount} rowCount={remoteRowCount}
rowHeight={this.cache.rowHeight} rowHeight={cache.rowHeight}
rowRenderer={this.rowRenderer} rowRenderer={rowRenderer}
scrollToAlignment="start" scrollToAlignment="start"
width={width || 1} width={width || 1}
overscanRowCount={20} overscanRowCount={20}
@@ -664,79 +770,22 @@ class JobOutput extends Component {
<OutputFooter /> <OutputFooter />
</OutputWrapper> </OutputWrapper>
</CardBody> </CardBody>
{showCancelPrompt && {error && (
['pending', 'waiting', 'running'].includes(jobStatus) && (
<I18n>
{({ i18n }) => (
<AlertModal <AlertModal
isOpen={showCancelPrompt} isOpen={error}
variant="danger" variant="danger"
onClose={this.handleCancelClose} onClose={dismissError}
title={i18n._(t`Cancel Job`)}
label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger"
isDisabled={cancelInProgress}
aria-label={i18n._(t`Cancel job`)}
onClick={this.handleCancelConfirm}
>
{i18n._(t`Cancel job`)}
</Button>,
<Button
id="cancel-job-return-button"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={this.handleCancelClose}
>
{i18n._(t`Return`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to submit the request to cancel this job?`
)}
</AlertModal>
)}
</I18n>
)}
{cancelError && (
<I18n>
{({ i18n }) => (
<AlertModal
isOpen={cancelError}
variant="danger"
onClose={() => this.setState({ cancelError: null })}
title={i18n._(t`Job Cancel Error`)}
label={i18n._(t`Job Cancel Error`)}
>
<ErrorDetail error={cancelError} />
</AlertModal>
)}
</I18n>
)}
{deletionError && (
<I18n>
{({ i18n }) => (
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)} title={i18n._(t`Job Delete Error`)}
label={i18n._(t`Job Delete Error`)} label={i18n._(t`Job Delete Error`)}
> >
<ErrorDetail error={deletionError} /> <ErrorDetail error={error} />
</AlertModal> </AlertModal>
)} )}
</I18n> </>
)} )}
</Fragment> </I18n>
); );
} }
}
export { JobOutput as _JobOutput }; export { JobOutput as _JobOutput };
export default withRouter(JobOutput); export default withRouter(JobOutput);

View File

@@ -1,15 +1,43 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import JobOutput, { _JobOutput } from './JobOutput'; import JobOutput from './JobOutput';
import { JobsAPI } from '../../../api'; import { JobsAPI } from '../../../api';
import mockJobData from '../shared/data.job.json'; import mockJobData from '../shared/data.job.json';
import mockJobEventsData from './data.job_events.json'; import mockJobEventsData from './data.job_events.json';
import mockFilteredJobEventsData from './data.filtered_job_events.json';
jest.mock('../../../api'); jest.mock('../../../api');
const generateChattyRows = () => {
const rows = [
'',
'PLAY [all] *********************************************************************16:17:13',
'',
'TASK [debug] *******************************************************************16:17:13',
];
for (let i = 1; i < 95; i++) {
rows.push(
`ok: [localhost] => (item=${i}) => {`,
` "msg": "This is a debug message: ${i}"`,
'}'
);
}
rows.push(
'',
'PLAY RECAP *********************************************************************16:17:15',
'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ',
''
);
return rows;
};
async function checkOutput(wrapper, expectedLines) { async function checkOutput(wrapper, expectedLines) {
await waitForElement(wrapper, 'div[type="job_event"]', el => el.length > 1); await waitForElement(wrapper, 'div[type="job_event"]', el => el.length > 1);
const jobEventLines = wrapper.find('JobEventLineText div'); const jobEventLines = wrapper.find('JobEventLineText div');
@@ -41,11 +69,19 @@ async function findScrollButtons(wrapper) {
}; };
} }
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
);
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
);
describe('<JobOutput />', () => { describe('<JobOutput />', () => {
let wrapper; let wrapper;
const mockJob = mockJobData; const mockJob = mockJobData;
const mockJobEvents = mockJobEventsData; const mockJobEvents = mockJobEventsData;
const scrollMock = jest.fn();
beforeEach(() => { beforeEach(() => {
JobsAPI.readEvents.mockResolvedValue({ JobsAPI.readEvents.mockResolvedValue({
@@ -64,289 +100,194 @@ describe('<JobOutput />', () => {
}); });
test('initially renders succesfully', async () => { test('initially renders succesfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
await checkOutput(wrapper, [
'ok: [localhost] => (item=37) => {', await checkOutput(wrapper, generateChattyRows());
' "msg": "This is a debug message: 37"',
'}',
'ok: [localhost] => (item=38) => {',
' "msg": "This is a debug message: 38"',
'}',
'ok: [localhost] => (item=39) => {',
' "msg": "This is a debug message: 39"',
'}',
'ok: [localhost] => (item=40) => {',
' "msg": "This is a debug message: 40"',
'}',
'ok: [localhost] => (item=41) => {',
' "msg": "This is a debug message: 41"',
'}',
'ok: [localhost] => (item=42) => {',
' "msg": "This is a debug message: 42"',
'}',
'ok: [localhost] => (item=43) => {',
' "msg": "This is a debug message: 43"',
'}',
'ok: [localhost] => (item=44) => {',
' "msg": "This is a debug message: 44"',
'}',
'ok: [localhost] => (item=45) => {',
' "msg": "This is a debug message: 45"',
'}',
'ok: [localhost] => (item=46) => {',
' "msg": "This is a debug message: 46"',
'}',
'ok: [localhost] => (item=47) => {',
' "msg": "This is a debug message: 47"',
'}',
'ok: [localhost] => (item=48) => {',
' "msg": "This is a debug message: 48"',
'}',
'ok: [localhost] => (item=49) => {',
' "msg": "This is a debug message: 49"',
'}',
'ok: [localhost] => (item=50) => {',
' "msg": "This is a debug message: 50"',
'}',
'ok: [localhost] => (item=51) => {',
' "msg": "This is a debug message: 51"',
'}',
'ok: [localhost] => (item=52) => {',
' "msg": "This is a debug message: 52"',
'}',
'ok: [localhost] => (item=53) => {',
' "msg": "This is a debug message: 53"',
'}',
'ok: [localhost] => (item=54) => {',
' "msg": "This is a debug message: 54"',
'}',
'ok: [localhost] => (item=55) => {',
' "msg": "This is a debug message: 55"',
'}',
'ok: [localhost] => (item=56) => {',
' "msg": "This is a debug message: 56"',
'}',
'ok: [localhost] => (item=57) => {',
' "msg": "This is a debug message: 57"',
'}',
'ok: [localhost] => (item=58) => {',
' "msg": "This is a debug message: 58"',
'}',
'ok: [localhost] => (item=59) => {',
' "msg": "This is a debug message: 59"',
'}',
'ok: [localhost] => (item=60) => {',
' "msg": "This is a debug message: 60"',
'}',
'ok: [localhost] => (item=61) => {',
' "msg": "This is a debug message: 61"',
'}',
'ok: [localhost] => (item=62) => {',
' "msg": "This is a debug message: 62"',
'}',
'ok: [localhost] => (item=63) => {',
' "msg": "This is a debug message: 63"',
'}',
'ok: [localhost] => (item=64) => {',
' "msg": "This is a debug message: 64"',
'}',
'ok: [localhost] => (item=65) => {',
' "msg": "This is a debug message: 65"',
'}',
'ok: [localhost] => (item=66) => {',
' "msg": "This is a debug message: 66"',
'}',
'ok: [localhost] => (item=67) => {',
' "msg": "This is a debug message: 67"',
'}',
'ok: [localhost] => (item=68) => {',
' "msg": "This is a debug message: 68"',
'}',
'ok: [localhost] => (item=69) => {',
' "msg": "This is a debug message: 69"',
'}',
'ok: [localhost] => (item=70) => {',
' "msg": "This is a debug message: 70"',
'}',
'ok: [localhost] => (item=71) => {',
' "msg": "This is a debug message: 71"',
'}',
'ok: [localhost] => (item=72) => {',
' "msg": "This is a debug message: 72"',
'}',
'ok: [localhost] => (item=73) => {',
' "msg": "This is a debug message: 73"',
'}',
'ok: [localhost] => (item=74) => {',
' "msg": "This is a debug message: 74"',
'}',
'ok: [localhost] => (item=75) => {',
' "msg": "This is a debug message: 75"',
'}',
'ok: [localhost] => (item=76) => {',
' "msg": "This is a debug message: 76"',
'}',
'ok: [localhost] => (item=77) => {',
' "msg": "This is a debug message: 77"',
'}',
'ok: [localhost] => (item=78) => {',
' "msg": "This is a debug message: 78"',
'}',
'ok: [localhost] => (item=79) => {',
' "msg": "This is a debug message: 79"',
'}',
'ok: [localhost] => (item=80) => {',
' "msg": "This is a debug message: 80"',
'}',
'ok: [localhost] => (item=81) => {',
' "msg": "This is a debug message: 81"',
'}',
'ok: [localhost] => (item=82) => {',
' "msg": "This is a debug message: 82"',
'}',
'ok: [localhost] => (item=83) => {',
' "msg": "This is a debug message: 83"',
'}',
'ok: [localhost] => (item=84) => {',
' "msg": "This is a debug message: 84"',
'}',
'ok: [localhost] => (item=85) => {',
' "msg": "This is a debug message: 85"',
'}',
'ok: [localhost] => (item=86) => {',
' "msg": "This is a debug message: 86"',
'}',
'ok: [localhost] => (item=87) => {',
' "msg": "This is a debug message: 87"',
'}',
'ok: [localhost] => (item=88) => {',
' "msg": "This is a debug message: 88"',
'}',
'ok: [localhost] => (item=89) => {',
' "msg": "This is a debug message: 89"',
'}',
'ok: [localhost] => (item=90) => {',
' "msg": "This is a debug message: 90"',
'}',
'ok: [localhost] => (item=91) => {',
' "msg": "This is a debug message: 91"',
'}',
'ok: [localhost] => (item=92) => {',
' "msg": "This is a debug message: 92"',
'}',
'ok: [localhost] => (item=93) => {',
' "msg": "This is a debug message: 93"',
'}',
'ok: [localhost] => (item=94) => {',
' "msg": "This is a debug message: 94"',
'}',
'',
'PLAY RECAP *********************************************************************15:37:26',
'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ',
'',
]);
expect(wrapper.find('JobOutput').length).toBe(1); expect(wrapper.find('JobOutput').length).toBe(1);
}); });
test('should call scrollToRow with expected index when scroll "previous" button is clicked', async () => { test('navigation buttons should display output properly', async () => {
const handleScrollPrevious = jest.spyOn( Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
_JobOutput.prototype, configurable: true,
'handleScrollPrevious' value: 10,
); });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: 100,
});
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
const { scrollLastButton, scrollPreviousButton } = await findScrollButtons( const {
wrapper scrollFirstButton,
scrollLastButton,
scrollPreviousButton,
} = await findScrollButtons(wrapper);
let jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(0).prop('stdout')).toBe('');
expect(jobEvents.at(1).prop('stdout')).toBe(
'\r\nPLAY [all] *********************************************************************'
); );
wrapper.find('JobOutput').instance().scrollToRow = scrollMock; await act(async () => {
scrollLastButton.simulate('click'); scrollLastButton.simulate('click');
});
wrapper.update();
jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
);
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe('');
await act(async () => {
scrollPreviousButton.simulate('click'); scrollPreviousButton.simulate('click');
expect(handleScrollPrevious).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(2);
expect(scrollMock.mock.calls).toEqual([[100], [0]]);
}); });
wrapper.update();
test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async () => { jobEvents = wrapper.find('JobEvent');
const handleScrollFirst = jest.spyOn( expect(jobEvents.at(0).prop('stdout')).toBe(
_JobOutput.prototype, '\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
'handleScrollFirst'
); );
wrapper = mountWithContexts(<JobOutput job={mockJob} />); expect(jobEvents.at(1).prop('stdout')).toBe(
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); '\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
const { scrollFirstButton, scrollLastButton } = await findScrollButtons(
wrapper
); );
wrapper.find('JobOutput').instance().scrollToRow = scrollMock; await act(async () => {
scrollFirstButton.simulate('click'); scrollFirstButton.simulate('click');
scrollLastButton.simulate('click');
scrollFirstButton.simulate('click');
expect(handleScrollFirst).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(3);
expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]);
}); });
wrapper.update();
test('should call scrollToRow with expected index on when scroll "last" button is clicked', async () => { jobEvents = wrapper.find('JobEvent');
const handleScrollLast = jest.spyOn( expect(jobEvents.at(0).prop('stdout')).toBe('');
_JobOutput.prototype, expect(jobEvents.at(1).prop('stdout')).toBe(
'handleScrollLast' '\r\nPLAY [all] *********************************************************************'
); );
wrapper = mountWithContexts(<JobOutput job={mockJob} />); await act(async () => {
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper
.find('JobOutput')
.instance()
.handleResize({ width: 100 });
const { scrollLastButton } = await findScrollButtons(wrapper);
wrapper.find('JobOutput').instance().scrollToRow = scrollMock;
scrollLastButton.simulate('click'); scrollLastButton.simulate('click');
});
expect(handleScrollLast).toHaveBeenCalled(); wrapper.update();
expect(scrollMock).toHaveBeenCalledTimes(1); jobEvents = wrapper.find('JobEvent');
expect(scrollMock.mock.calls).toEqual([[100]]); expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
);
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe('');
Object.defineProperty(
HTMLElement.prototype,
'offsetHeight',
originalOffsetHeight
);
Object.defineProperty(
HTMLElement.prototype,
'offsetWidth',
originalOffsetWidth
);
}); });
test('should make expected api call for delete', async () => { test('should make expected api call for delete', async () => {
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
wrapper.find('button[aria-label="Delete"]').simulate('click'); await act(async () => {
await waitForElement( wrapper.find('DeleteButton').invoke('onConfirm')();
wrapper, });
'Modal',
el => el.props().isOpen === true && el.props().title === 'Delete Job'
);
wrapper.find('Modal button[aria-label="Delete"]').simulate('click');
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
}); });
test('should show error dialog for failed deletion', async () => { test('should show error dialog for failed deletion', async () => {
JobsAPI.destroy.mockRejectedValue(new Error({})); JobsAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: `/api/v2/jobs/${mockJob.id}`,
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
wrapper.find('button[aria-label="Delete"]').simulate('click'); await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
await waitForElement( await waitForElement(
wrapper, wrapper,
'Modal', 'Modal[title="Job Delete Error"]',
el => el.props().isOpen === true && el.props().title === 'Delete Job' el => el.length === 1
); );
wrapper.find('Modal button[aria-label="Delete"]').simulate('click'); await act(async () => {
await waitForElement(wrapper, 'Modal ErrorDetail'); wrapper.find('Modal[title="Job Delete Error"]').invoke('onClose')();
const errorModalCloseBtn = wrapper.find( });
'ModalBox[aria-label="Job Delete Error"] ModalBoxCloseButton' await waitForElement(
wrapper,
'Modal[title="Job Delete Error"]',
el => el.length === 0
);
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
});
test('filter should be enabled after job finishes', async () => {
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
expect(wrapper.find('Search').props().isDisabled).toBe(false);
});
test('filter should be disabled while job is running', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobOutput job={{ ...mockJob, status: 'running' }} />
);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
expect(wrapper.find('Search').props().isDisabled).toBe(true);
});
test('filter should trigger api call and display correct rows', async () => {
const searchBtn = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
JobsAPI.readEvents.mockClear();
JobsAPI.readEvents.mockResolvedValueOnce({
data: mockFilteredJobEventsData,
});
await act(async () => {
wrapper.find(searchTextInput).instance().value = '99';
wrapper.find(searchTextInput).simulate('change');
});
wrapper.update();
await act(async () => {
wrapper.find(searchBtn).simulate('click');
});
wrapper.update();
expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, undefined, {
order_by: 'start_line',
page: 1,
page_size: 50,
stdout__icontains: '99',
});
const jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(0).prop('stdout')).toBe(
'\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
);
expect(jobEvents.at(1).prop('stdout')).toBe(
'\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
); );
errorModalCloseBtn.simulate('click');
await waitForElement(wrapper, 'Modal ErrorDetail', el => el.length === 0);
}); });
test('should throw error', async () => { test('should throw error', async () => {
JobsAPI.readEvents = () => Promise.reject(new Error()); JobsAPI.readEvents = () => Promise.reject(new Error());
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ const BarWrapper = styled.div`
background-color: #d7d7d7; background-color: #d7d7d7;
display: flex; display: flex;
height: 5px; height: 5px;
margin: 24px 0; margin-top: 16px;
width: 100%; width: 100%;
`; `;

View File

@@ -0,0 +1,29 @@
export default function getRowRangePageSize(startIndex, stopIndex) {
let page;
let pageSize;
if (startIndex === stopIndex) {
page = startIndex;
pageSize = 1;
} else if (stopIndex >= startIndex + 50) {
page = Math.ceil(startIndex / 50);
pageSize = 50;
} else {
for (let i = stopIndex - startIndex + 1; i <= 50; i++) {
if (
Math.floor(startIndex / i) === Math.floor(stopIndex / i) ||
i === 50
) {
page = Math.floor(startIndex / i) + 1;
pageSize = i;
break;
}
}
}
return {
page,
pageSize,
firstIndex: (page - 1) * pageSize,
};
}

View File

@@ -0,0 +1,32 @@
import getRowRangePageSize from './jobOutputUtils';
describe('getRowRangePageSize', () => {
test('handles range of 1', () => {
expect(getRowRangePageSize(1, 1)).toEqual({
page: 1,
pageSize: 1,
firstIndex: 0,
});
});
test('handles range larger than 50 rows', () => {
expect(getRowRangePageSize(55, 125)).toEqual({
page: 2,
pageSize: 50,
firstIndex: 50,
});
});
test('handles small range', () => {
expect(getRowRangePageSize(47, 53)).toEqual({
page: 6,
pageSize: 9,
firstIndex: 45,
});
});
test('handles perfect range', () => {
expect(getRowRangePageSize(5, 9)).toEqual({
page: 2,
pageSize: 5,
firstIndex: 5,
});
});
});

View File

@@ -0,0 +1,3 @@
export default function isJobRunning(status) {
return ['new', 'pending', 'waiting', 'running'].includes(status);
}

View File

@@ -0,0 +1,25 @@
import isJobRunning from './jobs';
describe('isJobRunning', () => {
test('should return true for new', () => {
expect(isJobRunning('new')).toBe(true);
});
test('should return true for pending', () => {
expect(isJobRunning('pending')).toBe(true);
});
test('should return true for waiting', () => {
expect(isJobRunning('waiting')).toBe(true);
});
test('should return true for running', () => {
expect(isJobRunning('running')).toBe(true);
});
test('should return false for canceled', () => {
expect(isJobRunning('canceled')).toBe(false);
});
test('should return false for successful', () => {
expect(isJobRunning('successful')).toBe(false);
});
test('should return false for failed', () => {
expect(isJobRunning('failed')).toBe(false);
});
});