mirror of
https://github.com/ansible/awx.git
synced 2026-03-22 11:25:08 -02:30
Merge pull request #5847 from marshmalien/4221-job-output-header
Add job event summary toolbar Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -5,17 +5,24 @@ import { Button } from '@patternfly/react-core';
|
|||||||
import AlertModal from '@components/AlertModal';
|
import AlertModal from '@components/AlertModal';
|
||||||
import { CardActionsRow } from '@components/Card';
|
import { CardActionsRow } from '@components/Card';
|
||||||
|
|
||||||
function DeleteButton({ onConfirm, modalTitle, name, i18n }) {
|
function DeleteButton({
|
||||||
|
onConfirm,
|
||||||
|
modalTitle,
|
||||||
|
name,
|
||||||
|
i18n,
|
||||||
|
variant,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant={variant || 'danger'}
|
||||||
aria-label={i18n._(t`Delete`)}
|
aria-label={i18n._(t`Delete`)}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{children || i18n._(t`Delete`)}
|
||||||
</Button>
|
</Button>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const Separator = styled.span`
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
margin-right: 27px;
|
margin-right: 20px;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
background-color: #d7d7d7;
|
background-color: #d7d7d7;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
AutoSizer,
|
AutoSizer,
|
||||||
@@ -8,18 +11,39 @@ import {
|
|||||||
List,
|
List,
|
||||||
} from 'react-virtualized';
|
} from 'react-virtualized';
|
||||||
|
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
import { CardBody } from '@components/Card';
|
import { 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 { StatusIcon } from '@components/Sparkline';
|
||||||
|
|
||||||
import JobEvent from './JobEvent';
|
import JobEvent from './JobEvent';
|
||||||
import JobEventSkeleton from './JobEventSkeleton';
|
import JobEventSkeleton from './JobEventSkeleton';
|
||||||
import PageControls from './PageControls';
|
import PageControls from './PageControls';
|
||||||
import HostEventModal from './HostEventModal';
|
import HostEventModal from './HostEventModal';
|
||||||
import { HostStatusBar } from './shared';
|
import { HostStatusBar, OutputToolbar } from './shared';
|
||||||
import { JobsAPI } from '@api';
|
import {
|
||||||
|
JobsAPI,
|
||||||
|
ProjectUpdatesAPI,
|
||||||
|
SystemJobsAPI,
|
||||||
|
WorkflowJobsAPI,
|
||||||
|
InventoriesAPI,
|
||||||
|
AdHocCommandsAPI,
|
||||||
|
} from '@api';
|
||||||
|
|
||||||
|
const HeaderTitle = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
h1 {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-weight: var(--pf-global--FontWeight--bold);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const OutputHeader = styled.div`
|
const OutputHeader = styled.div`
|
||||||
font-weight: var(--pf-global--FontWeight--bold);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const OutputWrapper = styled.div`
|
const OutputWrapper = styled.div`
|
||||||
@@ -31,6 +55,7 @@ const OutputWrapper = styled.div`
|
|||||||
height: calc(100vh - 350px);
|
height: calc(100vh - 350px);
|
||||||
outline: 1px solid #d7d7d7;
|
outline: 1px solid #d7d7d7;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const OutputFooter = styled.div`
|
const OutputFooter = styled.div`
|
||||||
background-color: #ebebeb;
|
background-color: #ebebeb;
|
||||||
border-right: 1px solid #d7d7d7;
|
border-right: 1px solid #d7d7d7;
|
||||||
@@ -52,6 +77,7 @@ class JobOutput extends Component {
|
|||||||
this.listRef = React.createRef();
|
this.listRef = React.createRef();
|
||||||
this.state = {
|
this.state = {
|
||||||
contentError: null,
|
contentError: null,
|
||||||
|
deletionError: null,
|
||||||
hasContentLoading: true,
|
hasContentLoading: true,
|
||||||
results: {},
|
results: {},
|
||||||
currentlyLoading: [],
|
currentlyLoading: [],
|
||||||
@@ -67,6 +93,7 @@ class JobOutput extends Component {
|
|||||||
|
|
||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
this.loadJobEvents = this.loadJobEvents.bind(this);
|
this.loadJobEvents = this.loadJobEvents.bind(this);
|
||||||
|
this.handleDeleteJob = this.handleDeleteJob.bind(this);
|
||||||
this.rowRenderer = this.rowRenderer.bind(this);
|
this.rowRenderer = this.rowRenderer.bind(this);
|
||||||
this.handleHostEventClick = this.handleHostEventClick.bind(this);
|
this.handleHostEventClick = this.handleHostEventClick.bind(this);
|
||||||
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
||||||
@@ -143,6 +170,34 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleDeleteJob() {
|
||||||
|
const { job, history } = this.props;
|
||||||
|
try {
|
||||||
|
switch (job.type) {
|
||||||
|
case 'project_update':
|
||||||
|
await ProjectUpdatesAPI.destroy(job.id);
|
||||||
|
break;
|
||||||
|
case 'system_job':
|
||||||
|
await SystemJobsAPI.destroy(job.id);
|
||||||
|
break;
|
||||||
|
case 'workflow_job':
|
||||||
|
await WorkflowJobsAPI.destroy(job.id);
|
||||||
|
break;
|
||||||
|
case 'ad_hoc_command':
|
||||||
|
await AdHocCommandsAPI.destroy(job.id);
|
||||||
|
break;
|
||||||
|
case 'inventory_update':
|
||||||
|
await InventoriesAPI.destroy(job.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await JobsAPI.destroy(job.id);
|
||||||
|
}
|
||||||
|
history.push('/jobs');
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ deletionError: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isRowLoaded({ index }) {
|
isRowLoaded({ index }) {
|
||||||
const { results, currentlyLoading } = this.state;
|
const { results, currentlyLoading } = this.state;
|
||||||
if (results[index]) {
|
if (results[index]) {
|
||||||
@@ -279,9 +334,11 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { job } = this.props;
|
const { job, i18n } = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contentError,
|
contentError,
|
||||||
|
deletionError,
|
||||||
hasContentLoading,
|
hasContentLoading,
|
||||||
hostEvent,
|
hostEvent,
|
||||||
isHostModalOpen,
|
isHostModalOpen,
|
||||||
@@ -305,7 +362,13 @@ class JobOutput extends Component {
|
|||||||
hostEvent={hostEvent}
|
hostEvent={hostEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<OutputHeader>{job.name}</OutputHeader>
|
<OutputHeader>
|
||||||
|
<HeaderTitle>
|
||||||
|
<StatusIcon status={job.status} />
|
||||||
|
<h1>{job.name}</h1>
|
||||||
|
</HeaderTitle>
|
||||||
|
<OutputToolbar job={job} onDelete={this.handleDeleteJob} />
|
||||||
|
</OutputHeader>
|
||||||
<HostStatusBar counts={job.host_status_counts} />
|
<HostStatusBar counts={job.host_status_counts} />
|
||||||
<PageControls
|
<PageControls
|
||||||
onScrollFirst={this.handleScrollFirst}
|
onScrollFirst={this.handleScrollFirst}
|
||||||
@@ -345,9 +408,20 @@ class JobOutput extends Component {
|
|||||||
</InfiniteLoader>
|
</InfiniteLoader>
|
||||||
<OutputFooter />
|
<OutputFooter />
|
||||||
</OutputWrapper>
|
</OutputWrapper>
|
||||||
|
{deletionError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deletionError}
|
||||||
|
variant="danger"
|
||||||
|
onClose={() => this.setState({ deletionError: null })}
|
||||||
|
title={i18n._(t`Job Delete Error`)}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default JobOutput;
|
export { JobOutput as _JobOutput };
|
||||||
|
export default withI18n()(withRouter(JobOutput));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput, { _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';
|
||||||
@@ -60,7 +60,7 @@ describe('<JobOutput />', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders succesfully', async done => {
|
test('initially renders succesfully', 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, [
|
await checkOutput(wrapper, [
|
||||||
@@ -119,12 +119,11 @@ describe('<JobOutput />', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(wrapper.find('JobOutput').length).toBe(1);
|
expect(wrapper.find('JobOutput').length).toBe(1);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call scrollToRow with expected index when scroll "previous" button is clicked', async done => {
|
test('should call scrollToRow with expected index when scroll "previous" button is clicked', async () => {
|
||||||
const handleScrollPrevious = jest.spyOn(
|
const handleScrollPrevious = jest.spyOn(
|
||||||
JobOutput.prototype,
|
_JobOutput.prototype,
|
||||||
'handleScrollPrevious'
|
'handleScrollPrevious'
|
||||||
);
|
);
|
||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
@@ -140,12 +139,11 @@ describe('<JobOutput />', () => {
|
|||||||
expect(handleScrollPrevious).toHaveBeenCalled();
|
expect(handleScrollPrevious).toHaveBeenCalled();
|
||||||
expect(scrollMock).toHaveBeenCalledTimes(2);
|
expect(scrollMock).toHaveBeenCalledTimes(2);
|
||||||
expect(scrollMock.mock.calls).toEqual([[100], [0]]);
|
expect(scrollMock.mock.calls).toEqual([[100], [0]]);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async done => {
|
test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async () => {
|
||||||
const handleScrollFirst = jest.spyOn(
|
const handleScrollFirst = jest.spyOn(
|
||||||
JobOutput.prototype,
|
_JobOutput.prototype,
|
||||||
'handleScrollFirst'
|
'handleScrollFirst'
|
||||||
);
|
);
|
||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
@@ -162,12 +160,11 @@ describe('<JobOutput />', () => {
|
|||||||
expect(handleScrollFirst).toHaveBeenCalled();
|
expect(handleScrollFirst).toHaveBeenCalled();
|
||||||
expect(scrollMock).toHaveBeenCalledTimes(3);
|
expect(scrollMock).toHaveBeenCalledTimes(3);
|
||||||
expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]);
|
expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call scrollToRow with expected index on when scroll "last" button is clicked', async done => {
|
test('should call scrollToRow with expected index on when scroll "last" button is clicked', async () => {
|
||||||
const handleScrollLast = jest.spyOn(
|
const handleScrollLast = jest.spyOn(
|
||||||
JobOutput.prototype,
|
_JobOutput.prototype,
|
||||||
'handleScrollLast'
|
'handleScrollLast'
|
||||||
);
|
);
|
||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
@@ -184,13 +181,43 @@ describe('<JobOutput />', () => {
|
|||||||
expect(handleScrollLast).toHaveBeenCalled();
|
expect(handleScrollLast).toHaveBeenCalled();
|
||||||
expect(scrollMock).toHaveBeenCalledTimes(1);
|
expect(scrollMock).toHaveBeenCalledTimes(1);
|
||||||
expect(scrollMock.mock.calls).toEqual([[100]]);
|
expect(scrollMock.mock.calls).toEqual([[100]]);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw error', async done => {
|
test('should make expected api call for delete', async () => {
|
||||||
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
|
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
||||||
|
wrapper.find('button[aria-label="Delete"]').simulate('click');
|
||||||
|
await waitForElement(
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error dialog for failed deletion', async () => {
|
||||||
|
JobsAPI.destroy.mockRejectedValue(new Error({}));
|
||||||
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
|
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
||||||
|
wrapper.find('button[aria-label="Delete"]').simulate('click');
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal',
|
||||||
|
el => el.props().isOpen === true && el.props().title === 'Delete Job'
|
||||||
|
);
|
||||||
|
wrapper.find('Modal button[aria-label="Delete"]').simulate('click');
|
||||||
|
await waitForElement(wrapper, 'Modal ErrorDetail');
|
||||||
|
const errorModalCloseBtn = wrapper.find(
|
||||||
|
'ModalBox div[aria-label="Job Delete Error"] button[aria-label="Close"]'
|
||||||
|
);
|
||||||
|
errorModalCloseBtn.simulate('click');
|
||||||
|
await waitForElement(wrapper, 'Modal ErrorDetail', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error', async () => {
|
||||||
JobsAPI.readEvents = () => Promise.reject(new Error());
|
JobsAPI.readEvents = () => Promise.reject(new Error());
|
||||||
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);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
172
awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
Normal file
172
awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { shape, func } from 'prop-types';
|
||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
RocketIcon,
|
||||||
|
TrashAltIcon,
|
||||||
|
} from '@patternfly/react-icons';
|
||||||
|
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
|
||||||
|
import VerticalSeparator from '@components/VerticalSeparator';
|
||||||
|
import DeleteButton from '@components/DeleteButton';
|
||||||
|
import LaunchButton from '@components/LaunchButton';
|
||||||
|
|
||||||
|
const BadgeGroup = styled.div`
|
||||||
|
margin-left: 20px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Badge = styled(PFBadge)`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
${props =>
|
||||||
|
props.color
|
||||||
|
? `
|
||||||
|
background-color: ${props.color}
|
||||||
|
color: white;
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const toHHMMSS = elapsed => {
|
||||||
|
const sec_num = parseInt(elapsed, 10);
|
||||||
|
const hours = Math.floor(sec_num / 3600);
|
||||||
|
const minutes = Math.floor(sec_num / 60) % 60;
|
||||||
|
const seconds = sec_num % 60;
|
||||||
|
|
||||||
|
const stampHours = hours < 10 ? `0${hours}` : hours;
|
||||||
|
const stampMinutes = minutes < 10 ? `0${minutes}` : minutes;
|
||||||
|
const stampSeconds = seconds < 10 ? `0${seconds}` : seconds;
|
||||||
|
|
||||||
|
return `${stampHours}:${stampMinutes}:${stampSeconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUTPUT_NO_COUNT_JOB_TYPES = [
|
||||||
|
'ad_hoc_command',
|
||||||
|
'system_job',
|
||||||
|
'inventory_update',
|
||||||
|
];
|
||||||
|
|
||||||
|
const OutputToolbar = ({ i18n, job, onDelete }) => {
|
||||||
|
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
||||||
|
|
||||||
|
const playCount = job?.playbook_counts?.play_count;
|
||||||
|
const taskCount = job?.playbook_counts?.task_count;
|
||||||
|
const darkCount = job?.host_status_counts?.dark;
|
||||||
|
const failureCount = job?.host_status_counts?.failures;
|
||||||
|
const totalHostCount = Object.keys(job?.host_status_counts || {}).reduce(
|
||||||
|
(sum, key) => sum + job?.host_status_counts[key],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
{!hideCounts && (
|
||||||
|
<>
|
||||||
|
{playCount > 0 && (
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Play Count`)}>
|
||||||
|
<div>{i18n._(t`Plays`)}</div>
|
||||||
|
<Badge isRead>{playCount}</Badge>
|
||||||
|
</BadgeGroup>
|
||||||
|
)}
|
||||||
|
{taskCount > 0 && (
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Task Count`)}>
|
||||||
|
<div>{i18n._(t`Tasks`)}</div>
|
||||||
|
<Badge isRead>{taskCount}</Badge>
|
||||||
|
</BadgeGroup>
|
||||||
|
)}
|
||||||
|
{totalHostCount > 0 && (
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Host Count`)}>
|
||||||
|
<div>{i18n._(t`Hosts`)}</div>
|
||||||
|
<Badge isRead>{totalHostCount}</Badge>
|
||||||
|
</BadgeGroup>
|
||||||
|
)}
|
||||||
|
{darkCount > 0 && (
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Unreachable Host Count`)}>
|
||||||
|
<div>{i18n._(t`Unreachable`)}</div>
|
||||||
|
<Tooltip content={i18n._(t`Unreachable Hosts`)}>
|
||||||
|
<Badge color="#470000" isRead>
|
||||||
|
{darkCount}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</BadgeGroup>
|
||||||
|
)}
|
||||||
|
{failureCount > 0 && (
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Failed Host Count`)}>
|
||||||
|
<div>{i18n._(t`Failed`)}</div>
|
||||||
|
<Tooltip content={i18n._(t`Failed Hosts`)}>
|
||||||
|
<Badge color="#C9190B" isRead>
|
||||||
|
{failureCount}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</BadgeGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BadgeGroup aria-label={i18n._(t`Elapsed Time`)}>
|
||||||
|
<div>{i18n._(t`Elapsed`)}</div>
|
||||||
|
<Tooltip content={i18n._(t`Elapsed time that the job ran`)}>
|
||||||
|
<Badge isRead>{toHHMMSS(job.elapsed)}</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</BadgeGroup>
|
||||||
|
|
||||||
|
<VerticalSeparator />
|
||||||
|
|
||||||
|
{job.type !== 'system_job' &&
|
||||||
|
job.summary_fields.user_capabilities?.start && (
|
||||||
|
<Tooltip content={i18n._(t`Relaunch Job`)}>
|
||||||
|
<LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}>
|
||||||
|
{({ handleRelaunch }) => (
|
||||||
|
<Button variant="plain" onClick={handleRelaunch}>
|
||||||
|
<RocketIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</LaunchButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.related?.stdout && (
|
||||||
|
<Tooltip content={i18n._(t`Download Output`)}>
|
||||||
|
<a href={`${job.related.stdout}?format=txt_download`}>
|
||||||
|
<Button variant="plain">
|
||||||
|
<DownloadIcon />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.summary_fields.user_capabilities.delete && (
|
||||||
|
<Tooltip content={i18n._(t`Delete Job`)}>
|
||||||
|
<DeleteButton
|
||||||
|
name={job.name}
|
||||||
|
modalTitle={i18n._(t`Delete Job`)}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<TrashAltIcon />
|
||||||
|
</DeleteButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
OutputToolbar.propTypes = {
|
||||||
|
job: shape({}).isRequired,
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(OutputToolbar);
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import { OutputToolbar } from '.';
|
||||||
|
import mockJobData from '../../shared/data.job.json';
|
||||||
|
|
||||||
|
describe('<OutputToolbar />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{
|
||||||
|
...mockJobData,
|
||||||
|
host_status_counts: {
|
||||||
|
dark: 1,
|
||||||
|
failures: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders without crashing', () => {
|
||||||
|
expect(wrapper.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide badge counts based on job type', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{ ...mockJobData, type: 'system_job' }}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('div[aria-label="Play Count"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Task Count"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Host Count"]').length).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('div[aria-label="Unreachable Host Count"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Failed Host Count"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Elapsed Time"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide badge if count is equal to zero', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{
|
||||||
|
...mockJobData,
|
||||||
|
host_status_counts: {},
|
||||||
|
playbook_counts: {},
|
||||||
|
}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('div[aria-label="Play Count"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Task Count"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Host Count"]').length).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('div[aria-label="Unreachable Host Count"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(wrapper.find('div[aria-label="Failed Host Count"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display elapsed time as HH:MM:SS', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{
|
||||||
|
...mockJobData,
|
||||||
|
elapsed: 274265,
|
||||||
|
}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('div[aria-label="Elapsed Time"] Badge').text()).toBe(
|
||||||
|
'76:11:05'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide relaunch button based on user capabilities', () => {
|
||||||
|
expect(wrapper.find('LaunchButton').length).toBe(1);
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{
|
||||||
|
...mockJobData,
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
start: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('LaunchButton').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide delete button based on user capabilities', () => {
|
||||||
|
expect(wrapper.find('DeleteButton').length).toBe(1);
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OutputToolbar
|
||||||
|
job={{
|
||||||
|
...mockJobData,
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('DeleteButton').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
export { default as HostStatusBar } from './HostStatusBar';
|
||||||
export { default as JobEventLine } from './JobEventLine';
|
export { default as JobEventLine } from './JobEventLine';
|
||||||
export { default as JobEventLineToggle } from './JobEventLineToggle';
|
export { default as JobEventLineToggle } from './JobEventLineToggle';
|
||||||
export { default as JobEventLineNumber } from './JobEventLineNumber';
|
export { default as JobEventLineNumber } from './JobEventLineNumber';
|
||||||
export { default as JobEventLineText } from './JobEventLineText';
|
export { default as JobEventLineText } from './JobEventLineText';
|
||||||
export { default as HostStatusBar } from './HostStatusBar';
|
export { default as OutputToolbar } from './OutputToolbar';
|
||||||
|
|||||||
Reference in New Issue
Block a user