1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 23:51:09 +03:00

Add job event summary toolbar

This commit is contained in:
Marliana Lara 2020-02-07 13:59:28 -05:00
parent 6df00e1e4c
commit b00249b515
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
7 changed files with 411 additions and 25 deletions

View File

@ -5,17 +5,24 @@ import { Button } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import { CardActionsRow } from '@components/Card';
function DeleteButton({ onConfirm, modalTitle, name, i18n }) {
function DeleteButton({
onConfirm,
modalTitle,
name,
i18n,
variant,
children,
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
variant="danger"
variant={variant || 'danger'}
aria-label={i18n._(t`Delete`)}
onClick={() => setIsOpen(true)}
>
{i18n._(t`Delete`)}
{children || i18n._(t`Delete`)}
</Button>
<AlertModal
isOpen={isOpen}

View File

@ -5,7 +5,7 @@ const Separator = styled.span`
display: inline-block;
width: 1px;
height: 30px;
margin-right: 27px;
margin-right: 20px;
margin-left: 20px;
background-color: #d7d7d7;
vertical-align: middle;

View File

@ -1,4 +1,7 @@
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 {
AutoSizer,
@ -8,18 +11,39 @@ import {
List,
} from 'react-virtualized';
import AlertModal from '@components/AlertModal';
import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import ErrorDetail from '@components/ErrorDetail';
import { StatusIcon } from '@components/Sparkline';
import JobEvent from './JobEvent';
import JobEventSkeleton from './JobEventSkeleton';
import PageControls from './PageControls';
import HostEventModal from './HostEventModal';
import { HostStatusBar } from './shared';
import { JobsAPI } from '@api';
import { HostStatusBar, OutputToolbar } from './shared';
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`
font-weight: var(--pf-global--FontWeight--bold);
display: flex;
justify-content: space-between;
`;
const OutputWrapper = styled.div`
@ -31,6 +55,7 @@ const OutputWrapper = styled.div`
height: calc(100vh - 350px);
outline: 1px solid #d7d7d7;
`;
const OutputFooter = styled.div`
background-color: #ebebeb;
border-right: 1px solid #d7d7d7;
@ -52,6 +77,7 @@ class JobOutput extends Component {
this.listRef = React.createRef();
this.state = {
contentError: null,
deletionError: null,
hasContentLoading: true,
results: {},
currentlyLoading: [],
@ -67,6 +93,7 @@ class JobOutput extends Component {
this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this);
this.handleDeleteJob = this.handleDeleteJob.bind(this);
this.rowRenderer = this.rowRenderer.bind(this);
this.handleHostEventClick = this.handleHostEventClick.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.idd);
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 }) {
const { results, currentlyLoading } = this.state;
if (results[index]) {
@ -279,9 +334,11 @@ class JobOutput extends Component {
}
render() {
const { job } = this.props;
const { job, i18n } = this.props;
const {
contentError,
deletionError,
hasContentLoading,
hostEvent,
isHostModalOpen,
@ -305,7 +362,13 @@ class JobOutput extends Component {
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} />
<PageControls
onScrollFirst={this.handleScrollFirst}
@ -345,9 +408,20 @@ class JobOutput extends Component {
</InfiniteLoader>
<OutputFooter />
</OutputWrapper>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</CardBody>
);
}
}
export default JobOutput;
export { JobOutput as _JobOutput };
export default withI18n()(withRouter(JobOutput));

View File

@ -1,6 +1,6 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobOutput from './JobOutput';
import JobOutput, { _JobOutput } from './JobOutput';
import { JobsAPI } from '@api';
import mockJobData from '../shared/data.job.json';
import mockJobEventsData from './data.job_events.json';
@ -60,7 +60,7 @@ describe('<JobOutput />', () => {
wrapper.unmount();
});
test('initially renders succesfully', async done => {
test('initially renders succesfully', async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
await checkOutput(wrapper, [
@ -119,12 +119,11 @@ describe('<JobOutput />', () => {
]);
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(
JobOutput.prototype,
_JobOutput.prototype,
'handleScrollPrevious'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
@ -140,12 +139,11 @@ describe('<JobOutput />', () => {
expect(handleScrollPrevious).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(2);
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(
JobOutput.prototype,
_JobOutput.prototype,
'handleScrollFirst'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
@ -162,12 +160,11 @@ describe('<JobOutput />', () => {
expect(handleScrollFirst).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(3);
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(
JobOutput.prototype,
_JobOutput.prototype,
'handleScrollLast'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
@ -184,13 +181,43 @@ describe('<JobOutput />', () => {
expect(handleScrollLast).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(1);
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());
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View 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;
`;
function toHHMMSS(s) {
function pad(n) {
return `00${n}`.slice(-2);
}
const secs = s % 60;
s = (s - secs) / 60;
const mins = s % 60;
const hrs = (s - mins) / 60;
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
}
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);

View File

@ -0,0 +1,105 @@
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 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);
});
});

View File

@ -1,5 +1,6 @@
export { default as HostStatusBar } from './HostStatusBar';
export { default as JobEventLine } from './JobEventLine';
export { default as JobEventLineToggle } from './JobEventLineToggle';
export { default as JobEventLineNumber } from './JobEventLineNumber';
export { default as JobEventLineText } from './JobEventLineText';
export { default as HostStatusBar } from './HostStatusBar';
export { default as OutputToolbar } from './OutputToolbar';