mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
Add support for filtering and pagination on job output
This commit is contained in:
@@ -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 }) {
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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