mirror of
https://github.com/ansible/awx.git
synced 2026-08-01 02:19:55 -02:30
Merge pull request #9208 from mabashian/job-output-search-2
Add support for filtering and pagination on job output SUMMARY link #6612 link #5906 This PR adds the ability to filter job events and also includes logic to handle fetching filtered job events across different pages. Note that the verbosity dropdown included in #5906 is not included in this work. I don't think that's possible without api changes. As part of this work, I converted JobOutput.jsx from a class based component to a functional component. I've tried my best to make sure that all existing functionality has remained the same by comparing the experience of this branch to devel. Like the old UI, the output filter is disabled while the job is running. ISSUE TYPE Feature Pull Request COMPONENT NAME UI Reviewed-by: Jake McDermott <yo@jakemcdermott.me> Reviewed-by: Marliana Lara <marliana.lara@gmail.com>
This commit is contained in:
@@ -45,6 +45,7 @@ To learn more about Ansible Builder and Execution Environments, see: https://www
|
|||||||
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
|
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
|
||||||
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
|
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
|
||||||
- Replaced CodeMirror with AceEditor for editing template variables and notification templates https://github.com/ansible/awx/pull/9281
|
- Replaced CodeMirror with AceEditor for editing template variables and notification templates https://github.com/ansible/awx/pull/9281
|
||||||
|
- Added support for filtering and pagination on job output https://github.com/ansible/awx/pull/9208
|
||||||
|
|
||||||
# 17.1.0 (March 9th, 2021)
|
# 17.1.0 (March 9th, 2021)
|
||||||
- Addressed a security issue in AWX (CVE-2021-20253)
|
- Addressed a security issue in AWX (CVE-2021-20253)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { RocketIcon } from '@patternfly/react-icons';
|
import { RocketIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) {
|
function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n, ouiaId }) {
|
||||||
const [isOpen, setIsOPen] = useState(false);
|
const [isOpen, setIsOPen] = useState(false);
|
||||||
|
|
||||||
const onToggle = () => {
|
const onToggle = () => {
|
||||||
@@ -75,6 +75,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
ouiaId={ouiaId}
|
||||||
isPlain
|
isPlain
|
||||||
position={DropdownPosition.right}
|
position={DropdownPosition.right}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,25 @@ 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 {
|
||||||
|
Button,
|
||||||
|
Toolbar,
|
||||||
|
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 +36,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 +56,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 +160,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 +184,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 +199,15 @@ const OutputFooter = styled.div`
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SearchToolbar = styled(Toolbar)`
|
||||||
|
position: inherit !important;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchToolbarContent = styled(ToolbarContent)`
|
||||||
|
padding-left: 0px !important;
|
||||||
|
padding-right: 0px !important;
|
||||||
|
`;
|
||||||
|
|
||||||
let ws;
|
let ws;
|
||||||
function connectJobSocket({ type, id }, onMessage) {
|
function connectJobSocket({ type, id }, onMessage) {
|
||||||
ws = new WebSocket(
|
ws = new WebSocket(
|
||||||
@@ -219,194 +258,112 @@ 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 [cssMap, setCssMap] = useState({});
|
||||||
this.loadMoreRows = this.loadMoreRows.bind(this);
|
const [currentlyLoading, setCurrentlyLoading] = useState([]);
|
||||||
this.scrollToRow = this.scrollToRow.bind(this);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
this.monitorJobSocketCounter = this.monitorJobSocketCounter.bind(this);
|
const [hostEvent, setHostEvent] = useState({});
|
||||||
}
|
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
|
||||||
|
const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting');
|
||||||
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
|
const [remoteRowCount, setRemoteRowCount] = useState(0);
|
||||||
|
const [results, setResults] = useState({});
|
||||||
|
|
||||||
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.group_name === 'job_events') {
|
||||||
if (data.counter && data.counter > this.jobSocketCounter) {
|
if (data.counter && data.counter > jobSocketCounter.current) {
|
||||||
this.jobSocketCounter = data.counter;
|
jobSocketCounter.current = data.counter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
|
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
|
||||||
if (data.final_counter) {
|
if (data.final_counter) {
|
||||||
this.jobSocketCounter = data.final_counter;
|
jobSocketCounter.current = data.final_counter;
|
||||||
}
|
}
|
||||||
if (data.status) {
|
if (data.status) {
|
||||||
this.setState({ jobStatus: data.status });
|
setJobStatus(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 });
|
error: cancelError,
|
||||||
}
|
isLoading: isCancelling,
|
||||||
|
request: cancelJob,
|
||||||
async handleCancelConfirm() {
|
} = useRequest(
|
||||||
const { job, type } = this.props;
|
useCallback(async () => {
|
||||||
this.setState({ cancelInProgress: true });
|
|
||||||
try {
|
|
||||||
await JobsAPI.cancel(job.id, type);
|
await JobsAPI.cancel(job.id, type);
|
||||||
} catch (cancelError) {
|
}, [job.id, type]),
|
||||||
this.setState({ cancelError });
|
{}
|
||||||
} finally {
|
);
|
||||||
this.setState({ showCancelPrompt: false, cancelInProgress: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDeleteJob() {
|
const {
|
||||||
const { job, history } = this.props;
|
error: dismissableCancelError,
|
||||||
try {
|
dismissError: dismissCancelError,
|
||||||
|
} = useDismissableError(cancelError);
|
||||||
|
|
||||||
|
const {
|
||||||
|
request: deleteJob,
|
||||||
|
isLoading: isDeleting,
|
||||||
|
error: deleteError,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
case 'project_update':
|
case 'project_update':
|
||||||
await ProjectUpdatesAPI.destroy(job.id);
|
await ProjectUpdatesAPI.destroy(job.id);
|
||||||
@@ -427,63 +384,122 @@ 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: dismissableDeleteError,
|
||||||
|
dismissError: dismissDeleteError,
|
||||||
|
} = useDismissableError(deleteError);
|
||||||
|
|
||||||
|
const monitorJobSocketCounter = () => {
|
||||||
|
if (
|
||||||
|
jobSocketCounter.current === remoteRowCount &&
|
||||||
|
!isJobRunning(job.status)
|
||||||
|
) {
|
||||||
|
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 && fetchedEvents[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 +507,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,107 +524,202 @@ 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,
|
|
||||||
remoteRowCount,
|
|
||||||
cssMap,
|
|
||||||
jobStatus,
|
|
||||||
showCancelPrompt,
|
|
||||||
cancelError,
|
|
||||||
cancelInProgress,
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
if (hasContentLoading) {
|
const handleRemoveSearchTerm = (key, value) => {
|
||||||
return <ContentLoading />;
|
let oldParams = parseQueryString(QS_CONFIG, location.search);
|
||||||
|
if (parseInt(value, 10)) {
|
||||||
|
oldParams = removeParams(QS_CONFIG, oldParams, {
|
||||||
|
[key]: parseInt(value, 10),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
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 (contentError) {
|
if (contentError) {
|
||||||
return <ContentError error={contentError} />;
|
return <ContentError error={contentError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<I18n>
|
||||||
|
{({ i18n }) => (
|
||||||
|
<>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{isHostModalOpen && (
|
{isHostModalOpen && (
|
||||||
<HostEventModal
|
<HostEventModal
|
||||||
onClose={this.handleHostModalClose}
|
onClose={handleHostModalClose}
|
||||||
isOpen={isHostModalOpen}
|
isOpen={isHostModalOpen}
|
||||||
hostEvent={hostEvent}
|
hostEvent={hostEvent}
|
||||||
/>
|
/>
|
||||||
@@ -620,42 +732,75 @@ class JobOutput extends Component {
|
|||||||
<OutputToolbar
|
<OutputToolbar
|
||||||
job={job}
|
job={job}
|
||||||
jobStatus={jobStatus}
|
jobStatus={jobStatus}
|
||||||
onDelete={this.handleDeleteJob}
|
onCancel={() => setShowCancelModal(true)}
|
||||||
onCancel={this.handleCancelOpen}
|
onDelete={deleteJob}
|
||||||
|
isDeleteDisabled={isDeleting}
|
||||||
/>
|
/>
|
||||||
</OutputHeader>
|
</OutputHeader>
|
||||||
<HostStatusBar counts={job.host_status_counts} />
|
<HostStatusBar counts={job.host_status_counts} />
|
||||||
|
<SearchToolbar
|
||||||
|
id="job_output-toolbar"
|
||||||
|
clearAllFilters={handleRemoveAllSearchTerms}
|
||||||
|
collapseListedFiltersBreakpoint="lg"
|
||||||
|
clearFiltersButtonText={i18n._(t`Clear all filters`)}
|
||||||
|
>
|
||||||
|
<SearchToolbarContent>
|
||||||
|
<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>
|
||||||
|
</SearchToolbarContent>
|
||||||
|
</SearchToolbar>
|
||||||
<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 (
|
||||||
|
<>
|
||||||
|
{hasContentLoading ? (
|
||||||
|
<div style={{ width }}>
|
||||||
|
<ContentLoading />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<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}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
@@ -664,14 +809,11 @@ class JobOutput extends Component {
|
|||||||
<OutputFooter />
|
<OutputFooter />
|
||||||
</OutputWrapper>
|
</OutputWrapper>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
{showCancelPrompt &&
|
{showCancelModal && isJobRunning(job.status) && (
|
||||||
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
|
||||||
<I18n>
|
|
||||||
{({ i18n }) => (
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={showCancelPrompt}
|
isOpen={showCancelModal}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClose={this.handleCancelClose}
|
onClose={() => setShowCancelModal(false)}
|
||||||
title={i18n._(t`Cancel Job`)}
|
title={i18n._(t`Cancel Job`)}
|
||||||
label={i18n._(t`Cancel Job`)}
|
label={i18n._(t`Cancel Job`)}
|
||||||
actions={[
|
actions={[
|
||||||
@@ -679,9 +821,9 @@ class JobOutput extends Component {
|
|||||||
id="cancel-job-confirm-button"
|
id="cancel-job-confirm-button"
|
||||||
key="delete"
|
key="delete"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
isDisabled={cancelInProgress}
|
isDisabled={isCancelling}
|
||||||
aria-label={i18n._(t`Cancel job`)}
|
aria-label={i18n._(t`Cancel job`)}
|
||||||
onClick={this.handleCancelConfirm}
|
onClick={cancelJob}
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel job`)}
|
{i18n._(t`Cancel job`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -690,7 +832,7 @@ class JobOutput extends Component {
|
|||||||
key="cancel"
|
key="cancel"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
aria-label={i18n._(t`Return`)}
|
aria-label={i18n._(t`Return`)}
|
||||||
onClick={this.handleCancelClose}
|
onClick={() => setShowCancelModal(false)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Return`)}
|
{i18n._(t`Return`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -701,42 +843,33 @@ class JobOutput extends Component {
|
|||||||
)}
|
)}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</I18n>
|
{dismissableDeleteError && (
|
||||||
)}
|
|
||||||
{cancelError && (
|
|
||||||
<I18n>
|
|
||||||
{({ i18n }) => (
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={cancelError}
|
isOpen={dismissableDeleteError}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClose={() => this.setState({ cancelError: null })}
|
onClose={dismissDeleteError}
|
||||||
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={dismissableDeleteError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</I18n>
|
{dismissableCancelError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={dismissableCancelError}
|
||||||
|
variant="danger"
|
||||||
|
onClose={dismissCancelError}
|
||||||
|
title={i18n._(t`Job Cancel Error`)}
|
||||||
|
label={i18n._(t`Job Cancel Error`)}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={dismissableCancelError} />
|
||||||
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</>
|
||||||
|
)}
|
||||||
|
</I18n>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export { JobOutput as _JobOutput };
|
export { JobOutput as _JobOutput };
|
||||||
export default withRouter(JobOutput);
|
export default withRouter(JobOutput);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const PageControls = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-expand-collapse-lines-button"
|
||||||
aria-label={i18n._(t`Toggle expand/collapse event lines`)}
|
aria-label={i18n._(t`Toggle expand/collapse event lines`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
css="margin-right: auto"
|
css="margin-right: auto"
|
||||||
@@ -40,6 +41,7 @@ const PageControls = ({
|
|||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-scroll-previous-button"
|
||||||
aria-label={i18n._(t`Scroll previous`)}
|
aria-label={i18n._(t`Scroll previous`)}
|
||||||
onClick={onScrollPrevious}
|
onClick={onScrollPrevious}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -47,6 +49,7 @@ const PageControls = ({
|
|||||||
<AngleUpIcon />
|
<AngleUpIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-scroll-next-button"
|
||||||
aria-label={i18n._(t`Scroll next`)}
|
aria-label={i18n._(t`Scroll next`)}
|
||||||
onClick={onScrollNext}
|
onClick={onScrollNext}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -54,6 +57,7 @@ const PageControls = ({
|
|||||||
<AngleDownIcon />
|
<AngleDownIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-scroll-first-button"
|
||||||
aria-label={i18n._(t`Scroll first`)}
|
aria-label={i18n._(t`Scroll first`)}
|
||||||
onClick={onScrollFirst}
|
onClick={onScrollFirst}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -61,6 +65,7 @@ const PageControls = ({
|
|||||||
<AngleDoubleUpIcon />
|
<AngleDoubleUpIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-scroll-last-button"
|
||||||
aria-label={i18n._(t`Scroll last`)}
|
aria-label={i18n._(t`Scroll last`)}
|
||||||
onClick={onScrollLast}
|
onClick={onScrollLast}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
|||||||
3457
awx/ui_next/src/screens/Job/JobOutput/data.filtered_job_events.json
Normal file
3457
awx/ui_next/src/screens/Job/JobOutput/data.filtered_job_events.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { shape, func } from 'prop-types';
|
import { bool, shape, func } from 'prop-types';
|
||||||
import {
|
import {
|
||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
@@ -62,7 +62,14 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
|
|||||||
'inventory_update',
|
'inventory_update',
|
||||||
];
|
];
|
||||||
|
|
||||||
const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
const OutputToolbar = ({
|
||||||
|
i18n,
|
||||||
|
job,
|
||||||
|
onDelete,
|
||||||
|
onCancel,
|
||||||
|
isDeleteDisabled,
|
||||||
|
jobStatus,
|
||||||
|
}) => {
|
||||||
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
||||||
|
|
||||||
const playCount = job?.playbook_counts?.play_count;
|
const playCount = job?.playbook_counts?.play_count;
|
||||||
@@ -138,13 +145,17 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
|||||||
{job.status === 'failed' && job.type === 'job' ? (
|
{job.status === 'failed' && job.type === 'job' ? (
|
||||||
<LaunchButton resource={job}>
|
<LaunchButton resource={job}>
|
||||||
{({ handleRelaunch }) => (
|
{({ handleRelaunch }) => (
|
||||||
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
|
<ReLaunchDropDown
|
||||||
|
handleRelaunch={handleRelaunch}
|
||||||
|
ouiaId="job-output-relaunch-dropdown"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</LaunchButton>
|
</LaunchButton>
|
||||||
) : (
|
) : (
|
||||||
<LaunchButton resource={job}>
|
<LaunchButton resource={job}>
|
||||||
{({ handleRelaunch }) => (
|
{({ handleRelaunch }) => (
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-relaunch-button"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={handleRelaunch}
|
onClick={handleRelaunch}
|
||||||
aria-label={i18n._(t`Relaunch`)}
|
aria-label={i18n._(t`Relaunch`)}
|
||||||
@@ -160,7 +171,11 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
|||||||
{job.related?.stdout && (
|
{job.related?.stdout && (
|
||||||
<Tooltip content={i18n._(t`Download Output`)}>
|
<Tooltip content={i18n._(t`Download Output`)}>
|
||||||
<a href={`${job.related.stdout}?format=txt_download`}>
|
<a href={`${job.related.stdout}?format=txt_download`}>
|
||||||
<Button variant="plain" aria-label={i18n._(t`Download Output`)}>
|
<Button
|
||||||
|
ouiaId="job-output-download-button"
|
||||||
|
variant="plain"
|
||||||
|
aria-label={i18n._(t`Download Output`)}
|
||||||
|
>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -170,6 +185,7 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
|||||||
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
||||||
<Tooltip content={i18n._(t`Cancel Job`)}>
|
<Tooltip content={i18n._(t`Cancel Job`)}>
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="job-output-cancel-button"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
aria-label={i18n._(t`Cancel Job`)}
|
aria-label={i18n._(t`Cancel Job`)}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -178,17 +194,18 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.summary_fields.user_capabilities.delete &&
|
{job.summary_fields.user_capabilities.delete &&
|
||||||
['new', 'successful', 'failed', 'error', 'canceled'].includes(
|
['new', 'successful', 'failed', 'error', 'canceled'].includes(
|
||||||
jobStatus
|
jobStatus
|
||||||
) && (
|
) && (
|
||||||
<Tooltip content={i18n._(t`Delete Job`)}>
|
<Tooltip content={i18n._(t`Delete Job`)}>
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
|
ouiaId="job-output-delete-button"
|
||||||
name={job.name}
|
name={job.name}
|
||||||
modalTitle={i18n._(t`Delete Job`)}
|
modalTitle={i18n._(t`Delete Job`)}
|
||||||
onConfirm={onDelete}
|
onConfirm={onDelete}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
isDisabled={isDeleteDisabled}
|
||||||
>
|
>
|
||||||
<TrashAltIcon />
|
<TrashAltIcon />
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
@@ -199,8 +216,13 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
OutputToolbar.propTypes = {
|
OutputToolbar.propTypes = {
|
||||||
|
isDeleteDisabled: bool,
|
||||||
job: shape({}).isRequired,
|
job: shape({}).isRequired,
|
||||||
onDelete: func.isRequired,
|
onDelete: func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
OutputToolbar.defaultProps = {
|
||||||
|
isDeleteDisabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(OutputToolbar);
|
export default withI18n()(OutputToolbar);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export default function getRowRangePageSize(startIndex, stopIndex) {
|
||||||
|
let page;
|
||||||
|
let pageSize;
|
||||||
|
|
||||||
|
if (startIndex === stopIndex) {
|
||||||
|
page = startIndex + 1;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import getRowRangePageSize from './jobOutputUtils';
|
||||||
|
|
||||||
|
describe('getRowRangePageSize', () => {
|
||||||
|
test('handles range of 1', () => {
|
||||||
|
expect(getRowRangePageSize(1, 1)).toEqual({
|
||||||
|
page: 2,
|
||||||
|
pageSize: 1,
|
||||||
|
firstIndex: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
3
awx/ui_next/src/util/jobs.js
Normal file
3
awx/ui_next/src/util/jobs.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function isJobRunning(status) {
|
||||||
|
return ['new', 'pending', 'waiting', 'running'].includes(status);
|
||||||
|
}
|
||||||
25
awx/ui_next/src/util/jobs.test.js
Normal file
25
awx/ui_next/src/util/jobs.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user