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: "[0;33mchanged: [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"]}[0m"
+ `,
+ 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 && (