diff --git a/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx new file mode 100644 index 0000000000..69c2070582 --- /dev/null +++ b/awx/ui_next/src/components/Sparkline/HostStatusIcon.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import HostStatusIcon from './HostStatusIcon'; + +describe('HostStatusIcon', () => { + test('renders the "ok" host status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1); + }); + test('renders "failed" host status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1); + }); + test('renders "changed" host status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__ChangedTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__ChangedBottom')).toHaveLength(1); + }); + test('renders "skipped" host status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__SkippedTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__SkippedBottom')).toHaveLength(1); + }); + test('renders "unreachable" host status', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableTop')).toHaveLength(1); + expect(wrapper.find('StatusIcon__UnreachableBottom')).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx index 95c92863c7..7c826518a1 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx @@ -7,6 +7,7 @@ import { } from '@patternfly/react-core'; import CodeMirrorInput from '@components/CodeMirrorInput'; import ContentEmpty from '@components/ContentEmpty'; +import PropTypes from 'prop-types'; import { DetailList, Detail } from '@components/DetailList'; import { HostStatusIcon } from '@components/Sparkline'; import { withI18n } from '@lingui/react'; @@ -28,9 +29,10 @@ const Modal = styled(PFModal)` const HostNameDetailValue = styled.div` align-items: center; - display: inline-grid; - grid-gap: 10px; - grid-template-columns: min-content auto; + display: inline-flex; + > div { + margin-right: 10px; + } `; const Tabs = styled(PFTabs)` @@ -58,100 +60,107 @@ const Tabs = styled(PFTabs)` } `; -function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { +const processEventStatus = event => { + let status = null; + if (event.event === 'runner_on_unreachable') { + status = 'unreachable'; + } + // equiv to 'runner_on_error' && 'runner_on_failed' + if (event.failed) { + status = 'failed'; + } + if ( + event.event === 'runner_on_ok' || + event.event === 'runner_on_async_ok' || + event.event === 'runner_item_on_ok' + ) { + status = 'ok'; + } + // catch the 'changed' case after 'ok', because both can be true + if (event.changed) { + status = 'changed'; + } + if (event.event === 'runner_on_skipped') { + status = 'skipped'; + } + return status; +}; + +const processCodeMirrorValue = value => { + let codeMirrorValue; + if (value === undefined) { + codeMirrorValue = false; + } else if (value === '') { + codeMirrorValue = ' '; + } else if (typeof value === 'string') { + codeMirrorValue = entities.encode(value); + } else { + codeMirrorValue = value; + } + return codeMirrorValue; +}; + +const processStdOutValue = hostEvent => { + const { taskAction, res } = hostEvent.event_data; + let stdOut; + if (taskAction === 'debug' && res.result && res.result.stdout) { + stdOut = res.result.stdout; + } else if ( + taskAction === 'yum' && + res.results && + Array.isArray(res.results) + ) { + [stdOut] = res.results; + } else { + stdOut = res.stdout; + } + return stdOut; +}; + +function HostEventModal({ onClose, hostEvent = {}, isOpen = false, i18n }) { const [hostStatus, setHostStatus] = useState(null); const [activeTabKey, setActiveTabKey] = useState(0); useEffect(() => { - processEventStatus(hostEvent); + setHostStatus(processEventStatus(hostEvent)); }, []); const handleTabClick = (event, tabIndex) => { setActiveTabKey(tabIndex); }; - function processEventStatus(event) { - let status = null; - if (event.event === 'runner_on_unreachable') { - status = 'unreachable'; - } - // equiv to 'runner_on_error' && 'runner_on_failed' - if (event.failed) { - status = 'failed'; - } - // catch the 'changed' case before 'ok', because both can be true - if (event.changed) { - status = 'changed'; - } - if ( - event.event === 'runner_on_ok' || - event.event === 'runner_on_async_ok' || - event.event === 'runner_item_on_ok' - ) { - status = 'ok'; - } - if (event.event === 'runner_on_skipped') { - status = 'skipped'; - } - setHostStatus(status); - } - - function processStdOutValue() { - const { res } = hostEvent.event_data; - let stdOut; - if (taskAction === 'debug' && res.result && res.result.stdout) { - stdOut = processCodeMirrorValue(res.result.stdout); - } else if ( - taskAction === 'yum' && - res.results && - Array.isArray(res.results) - ) { - stdOut = processCodeMirrorValue(res.results[0]); - } else { - stdOut = processCodeMirrorValue(res.stdout); - } - return stdOut; - } - - const processCodeMirrorValue = value => { - let codeMirrorValue; - if (value === undefined) { - codeMirrorValue = false; - } else if (value === '') { - codeMirrorValue = ' '; - } else if (typeof value === 'string') { - codeMirrorValue = entities.encode(value); - } else { - codeMirrorValue = value; - } - return codeMirrorValue; - }; - - const taskAction = hostEvent.event_data.task_action; - const JSONObj = processCodeMirrorValue(hostEvent.event_data.res); - const StdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr); - const StdOut = processStdOutValue(); + const jsonObj = processCodeMirrorValue(hostEvent.event_data.res); + const stdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr); + const stdOut = processCodeMirrorValue(processStdOutValue(hostEvent)); return ( + , ]} > - - + + - {hostStatus && } + {hostStatus ? : null} {hostEvent.host_name} } @@ -160,7 +169,9 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { - - {activeTabKey === 1 && JSONObj ? ( + + {activeTabKey === 1 && jsonObj ? ( {}} rows={20} hasErrors={false} @@ -182,12 +197,16 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { )} - - {activeTabKey === 2 && StdOut ? ( + + {activeTabKey === 2 && stdOut ? ( {}} rows={20} hasErrors={false} @@ -196,13 +215,17 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { )} - - {activeTabKey === 3 && StdErr ? ( + + {activeTabKey === 3 && stdErr ? ( {}} - value={StdErr} + value={stdErr} hasErrors={false} rows={20} /> @@ -216,3 +239,14 @@ function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) { } export default withI18n()(HostEventModal); + +HostEventModal.propTypes = { + onClose: PropTypes.func.isRequired, + hostEvent: PropTypes.shape({}), + isOpen: PropTypes.bool, +}; + +HostEventModal.defaultProps = { + hostEvent: null, + isOpen: false, +}; diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx new file mode 100644 index 0000000000..716562ddb6 --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.test.jsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import HostEventModal from './HostEventModal'; + +const hostEvent = { + changed: true, + event: 'runner_on_ok', + event_data: { + host: 'foo', + play: 'all', + playbook: 'run_command.yml', + res: { + ansible_loop_var: 'item', + changed: true, + item: '1', + msg: 'This is a debug message: 1', + stdout: + ' total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023', + cmd: ['free', '-m'], + stderr_lines: [], + stdout_lines: [ + ' total used free shared buff/cache available', + 'Mem: 7973 3005 960 30 4007 4582', + 'Swap: 1023 0 1023', + ], + }, + task: 'command', + task_action: 'command', + }, + event_display: 'Host OK', + event_level: 3, + failed: false, + host: 1, + host_name: 'foo', + id: 123, + job: 4, + play: 'all', + playbook: 'run_command.yml', + stdout: `stdout: "changed: [localhost] => {"changed": true, "cmd": ["free", "-m"], "delta": "0:00:01.479609", "end": "2019-09-10 14:21:45.469533", "rc": 0, "start": "2019-09-10 14:21:43.989924", "stderr": "", "stderr_lines": [], "stdout": " total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023", "stdout_lines": [" total used free shared buff/cache available", "Mem: 7973 3005 960 30 4007 4582", "Swap: 1023 0 1023"]}" + `, + task: 'command', + type: 'job_event', + url: '/api/v2/job_events/123/', +}; + +/* eslint-disable no-useless-escape */ +const jsonValue = `{ + \"ansible_loop_var\": \"item\", + \"changed\": true, + \"item\": \"1\", + \"msg\": \"This is a debug message: 1\", + \"stdout\": \" total used free shared buff/cache available\\nMem: 7973 3005 960 30 4007 4582\\nSwap: 1023 0 1023\", + \"cmd\": [ + \"free\", + \"-m\" + ], + \"stderr_lines\": [], + \"stdout_lines\": [ + \" total used free shared buff/cache available\", + \"Mem: 7973 3005 960 30 4007 4582\", + \"Swap: 1023 0 1023\" + ] +}`; + +let detailsSection; +let jsonSection; +let standardOutSection; +let standardErrorSection; + +const findSections = wrapper => { + detailsSection = wrapper.find('section').at(0); + jsonSection = wrapper.find('section').at(1); + standardOutSection = wrapper.find('section').at(2); + standardErrorSection = wrapper.find('section').at(3); +}; + +describe('HostEventModal', () => { + test('initially renders successfully', () => { + const wrapper = mountWithContexts( + {}} /> + ); + expect(wrapper).toHaveLength(1); + }); + + test('should render all tabs', () => { + const wrapper = mountWithContexts( + {}} isOpen /> + ); + + /* eslint-disable react/button-has-type */ + expect( + wrapper + .find('Tabs') + .containsAllMatchingElements([ + , + , + , + , + ]) + ).toEqual(true); + }); + + test('should show details tab content on mount', () => { + const wrapper = mountWithContexts( + {}} isOpen /> + ); + findSections(wrapper); + expect(detailsSection.find('TextList').length).toBe(1); + + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Host Name', 'foo'); + assertDetail('Play', 'all'); + assertDetail('Task', 'command'); + assertDetail('Module', 'command'); + assertDetail('Command', 'free-m'); + }); + + test('should display successful host status icon', () => { + const successfulHostEvent = { ...hostEvent, changed: false }; + const wrapper = mountWithContexts( + {}} + isOpen + /> + ); + const icon = wrapper.find('HostStatusIcon'); + expect(icon.prop('status')).toBe('ok'); + expect(icon.find('StatusIcon__SuccessfulTop').length).toBe(1); + expect(icon.find('StatusIcon__SuccessfulBottom').length).toBe(1); + }); + + test('should display skipped host status icon', () => { + const skippedHostEvent = { ...hostEvent, event: 'runner_on_skipped' }; + const wrapper = mountWithContexts( + {}} isOpen /> + ); + + const icon = wrapper.find('HostStatusIcon'); + expect(icon.prop('status')).toBe('skipped'); + expect(icon.find('StatusIcon__SkippedTop').length).toBe(1); + expect(icon.find('StatusIcon__SkippedBottom').length).toBe(1); + }); + + test('should display unreachable host status icon', () => { + const unreachableHostEvent = { + ...hostEvent, + event: 'runner_on_unreachable', + changed: false, + }; + const wrapper = mountWithContexts( + {}} + isOpen + /> + ); + + const icon = wrapper.find('HostStatusIcon'); + expect(icon.prop('status')).toBe('unreachable'); + expect(icon.find('StatusIcon__UnreachableTop').length).toBe(1); + expect(icon.find('StatusIcon__UnreachableBottom').length).toBe(1); + }); + + test('should display failed host status icon', () => { + const unreachableHostEvent = { + ...hostEvent, + changed: false, + failed: true, + event: 'runner_on_failed', + }; + const wrapper = mountWithContexts( + {}} + isOpen + /> + ); + + const icon = wrapper.find('HostStatusIcon'); + expect(icon.prop('status')).toBe('failed'); + expect(icon.find('StatusIcon__FailedTop').length).toBe(1); + expect(icon.find('StatusIcon__FailedBottom').length).toBe(1); + }); + + test('should display JSON tab content on tab click', () => { + const wrapper = mountWithContexts( + {}} isOpen /> + ); + + findSections(wrapper); + expect(jsonSection.find('EmptyState').length).toBe(1); + wrapper.find('button[aria-label="JSON tab"]').simulate('click'); + findSections(wrapper); + expect(jsonSection.find('CodeMirrorInput').length).toBe(1); + + const codemirror = jsonSection.find('CodeMirrorInput Controlled'); + expect(codemirror.prop('mode')).toBe('javascript'); + expect(codemirror.prop('options').readOnly).toBe(true); + expect(codemirror.prop('value')).toEqual(jsonValue); + }); + + test('should display Standard Out tab content on tab click', () => { + const wrapper = mountWithContexts( + {}} isOpen /> + ); + + findSections(wrapper); + expect(standardOutSection.find('EmptyState').length).toBe(1); + wrapper.find('button[aria-label="Standard out tab"]').simulate('click'); + findSections(wrapper); + expect(standardOutSection.find('CodeMirrorInput').length).toBe(1); + + const codemirror = standardOutSection.find('CodeMirrorInput Controlled'); + expect(codemirror.prop('mode')).toBe('javascript'); + expect(codemirror.prop('options').readOnly).toBe(true); + expect(codemirror.prop('value')).toEqual(hostEvent.event_data.res.stdout); + }); + + test('should display Standard Error tab content on tab click', () => { + const hostEventError = { + ...hostEvent, + event_data: { + res: { + stderr: '', + }, + }, + }; + const wrapper = mountWithContexts( + {}} isOpen /> + ); + findSections(wrapper); + expect(standardErrorSection.find('EmptyState').length).toBe(1); + wrapper.find('button[aria-label="Standard error tab"]').simulate('click'); + findSections(wrapper); + expect(standardErrorSection.find('CodeMirrorInput').length).toBe(1); + + const codemirror = standardErrorSection.find('CodeMirrorInput Controlled'); + expect(codemirror.prop('mode')).toBe('javascript'); + expect(codemirror.prop('options').readOnly).toBe(true); + expect(codemirror.prop('value')).toEqual(' '); + }); + + test('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + const wrapper = mountWithContexts( + + ); + const closeButton = wrapper.find('ModalBoxFooter Button'); + closeButton.simulate('click'); + expect(onClose).toBeCalled(); + }); + + test('should render standard out of debug task', () => { + const debugTaskAction = { + ...hostEvent, + event_data: { + taskAction: 'debug', + res: { + result: { + stdout: 'foo bar', + }, + }, + }, + }; + const wrapper = mountWithContexts( + {}} isOpen /> + ); + wrapper.find('button[aria-label="Standard out tab"]').simulate('click'); + findSections(wrapper); + const codemirror = standardOutSection.find('CodeMirrorInput Controlled'); + expect(codemirror.prop('value')).toEqual('foo bar'); + }); + + test('should render standard out of yum task', () => { + const yumTaskAction = { + ...hostEvent, + event_data: { + taskAction: 'yum', + res: { + results: ['baz', 'bar'], + }, + }, + }; + const wrapper = mountWithContexts( + {}} isOpen /> + ); + wrapper.find('button[aria-label="Standard out tab"]').simulate('click'); + findSections(wrapper); + const codemirror = standardOutSection.find('CodeMirrorInput Controlled'); + expect(codemirror.prop('value')).toEqual('baz'); + }); +}); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index d5c928a99c..bde17d7b0d 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -304,7 +304,7 @@ class JobOutput extends Component { {isHostModalOpen && (