diff --git a/awx/ui/client/features/output/host-event/host-event.service.js b/awx/ui/client/features/output/host-event/host-event.service.js
index 1e0588b329..4709c1055f 100644
--- a/awx/ui/client/features/output/host-event/host-event.service.js
+++ b/awx/ui/client/features/output/host-event/host-event.service.js
@@ -66,15 +66,15 @@ function HostEventService (
obj.class = 'HostEvent-status--failed';
obj.status = 'failed';
}
- // catch the changed case before ok, because both can be true
- if (event.changed) {
- obj.class = 'HostEvent-status--changed';
- obj.status = 'changed';
- }
if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') {
obj.class = 'HostEvent-status--ok';
obj.status = 'ok';
}
+ // if both 'changed' and 'ok' are true, show 'changed' status
+ if (event.changed) {
+ obj.class = 'HostEvent-status--changed';
+ obj.status = 'changed';
+ }
if (event.event === 'runner_on_skipped') {
obj.class = 'HostEvent-status--skipped';
obj.status = 'skipped';
diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx
deleted file mode 100644
index 204d305394..0000000000
--- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.test.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-import { mount } from 'enzyme';
-import JobStatusIcon from './JobStatusIcon';
-
-describe('JobStatusIcon', () => {
- test('renders the successful job', () => {
- const wrapper = mount();
- expect(wrapper).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__SuccessfulTop')).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__SuccessfulBottom')).toHaveLength(1);
- });
- test('renders running job', () => {
- const wrapper = mount();
- expect(wrapper).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__RunningJob')).toHaveLength(1);
- });
- test('renders waiting job', () => {
- const wrapper = mount();
- expect(wrapper).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__WaitingJob')).toHaveLength(1);
- });
- test('renders failed job', () => {
- const wrapper = mount();
- expect(wrapper).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__FailedTop')).toHaveLength(1);
- expect(wrapper.find('JobStatusIcon__FailedBottom')).toHaveLength(1);
- });
-});
diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx
index 5291eeca85..57029ce73a 100644
--- a/awx/ui_next/src/components/Sparkline/Sparkline.jsx
+++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx
@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
import { arrayOf, object } from 'prop-types';
import { withI18n } from '@lingui/react';
import { Link as _Link } from 'react-router-dom';
-import { JobStatusIcon } from '@components/Sparkline';
+import { StatusIcon } from '@components/Sparkline';
import { Tooltip } from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
@@ -34,7 +34,7 @@ const Sparkline = ({ i18n, jobs }) => {
return jobs.map(job => (
-
+
));
diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx
index e39003a841..0d4d5579e9 100644
--- a/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx
+++ b/awx/ui_next/src/components/Sparkline/Sparkline.test.jsx
@@ -23,7 +23,7 @@ describe('Sparkline', () => {
},
];
const wrapper = mountWithContexts();
- expect(wrapper.find('JobStatusIcon')).toHaveLength(2);
+ expect(wrapper.find('StatusIcon')).toHaveLength(2);
expect(wrapper.find('Tooltip')).toHaveLength(2);
expect(wrapper.find('Link')).toHaveLength(2);
});
diff --git a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx b/awx/ui_next/src/components/Sparkline/StatusIcon.jsx
similarity index 59%
rename from awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx
rename to awx/ui_next/src/components/Sparkline/StatusIcon.jsx
index 8a462f72ed..5f048f5cb5 100644
--- a/awx/ui_next/src/components/Sparkline/JobStatusIcon.jsx
+++ b/awx/ui_next/src/components/Sparkline/StatusIcon.jsx
@@ -16,6 +16,18 @@ const Wrapper = styled.div`
height: 14px;
`;
+const WhiteTop = styled.div`
+ border: 1px solid #b7b7b7;
+ border-bottom: 0;
+ background: #ffffff;
+`;
+
+const WhiteBottom = styled.div`
+ border: 1px solid #b7b7b7;
+ border-top: 0;
+ background: #ffffff;
+`;
+
const RunningJob = styled(Wrapper)`
background-color: #5cb85c;
padding-right: 0px;
@@ -39,24 +51,29 @@ const FinishedJob = styled(Wrapper)`
const SuccessfulTop = styled.div`
background-color: #5cb85c;
`;
+const SuccessfulBottom = styled(WhiteBottom)``;
-const SuccessfulBottom = styled.div`
- border: 1px solid #b7b7b7;
- border-top: 0;
- background: #ffffff;
-`;
-
-const FailedTop = styled.div`
- border: 1px solid #b7b7b7;
- border-bottom: 0;
- background: #ffffff;
-`;
-
+const FailedTop = styled(WhiteTop)``;
const FailedBottom = styled.div`
background-color: #d9534f;
`;
-const JobStatusIcon = ({ status, ...props }) => {
+const UnreachableTop = styled(WhiteTop)``;
+const UnreachableBottom = styled.div`
+ background-color: #ff0000;
+`;
+
+const ChangedTop = styled(WhiteTop)``;
+const ChangedBottom = styled.div`
+ background-color: #ff9900;
+`;
+
+const SkippedTop = styled(WhiteTop)``;
+const SkippedBottom = styled.div`
+ background-color: #2dbaba;
+`;
+
+const StatusIcon = ({ status, ...props }) => {
return (
{status === 'running' && }
@@ -69,18 +86,36 @@ const JobStatusIcon = ({ status, ...props }) => {
)}
- {status === 'successful' && (
+ {(status === 'successful' || status === 'ok') && (
)}
+ {status === 'changed' && (
+
+
+
+
+ )}
+ {status === 'skipped' && (
+
+
+
+
+ )}
+ {status === 'unreachable' && (
+
+
+
+
+ )}
);
};
-JobStatusIcon.propTypes = {
+StatusIcon.propTypes = {
status: string.isRequired,
};
-export default JobStatusIcon;
+export default StatusIcon;
diff --git a/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx b/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx
new file mode 100644
index 0000000000..fd47a309c8
--- /dev/null
+++ b/awx/ui_next/src/components/Sparkline/StatusIcon.test.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import StatusIcon from './StatusIcon';
+
+describe('StatusIcon', () => {
+ test('renders the successful status', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
+ });
+ test('renders running status', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__RunningJob')).toHaveLength(1);
+ });
+ test('renders waiting status', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__WaitingJob')).toHaveLength(1);
+ });
+ test('renders failed status', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__FailedTop')).toHaveLength(1);
+ expect(wrapper.find('StatusIcon__FailedBottom')).toHaveLength(1);
+ });
+ test('renders a successful status when host status is "ok"', () => {
+ const wrapper = mount();
+ wrapper.debug();
+ 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/components/Sparkline/index.js b/awx/ui_next/src/components/Sparkline/index.js
index f33b83ae6c..f7b30c0d98 100644
--- a/awx/ui_next/src/components/Sparkline/index.js
+++ b/awx/ui_next/src/components/Sparkline/index.js
@@ -1,2 +1,2 @@
export { default as Sparkline } from './Sparkline';
-export { default as JobStatusIcon } from './JobStatusIcon';
+export { default as StatusIcon } from './StatusIcon';
diff --git a/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx
new file mode 100644
index 0000000000..08e3866983
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx
@@ -0,0 +1,254 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Button,
+ Modal as PFModal,
+ Tab,
+ Tabs as PFTabs,
+} 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 { StatusIcon } from '@components/Sparkline';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import Entities from 'html-entities';
+
+const entities = new Entities.AllHtmlEntities();
+
+const Modal = styled(PFModal)`
+ --pf-c-modal-box__footer--MarginTop: 0;
+ align-self: flex-start;
+ margin-top: 200px;
+ .pf-c-modal-box__body {
+ overflow-y: hidden;
+ }
+ .pf-c-tab-content {
+ padding: 24px 0;
+ }
+`;
+
+const HostNameDetailValue = styled.div`
+ align-items: center;
+ display: inline-flex;
+ > div {
+ margin-right: 10px;
+ }
+`;
+
+const Tabs = styled(PFTabs)`
+ --pf-c-tabs__button--PaddingLeft: 20px;
+ --pf-c-tabs__button--PaddingRight: 20px;
+
+ .pf-c-tabs__list {
+ li:first-of-type .pf-c-tabs__button {
+ &::after {
+ margin-left: 0;
+ }
+ }
+ }
+
+ &:not(.pf-c-tabs__item)::before {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ content: '';
+ border-bottom: solid var(--pf-c-tabs__item--BorderColor);
+ border-width: var(--pf-c-tabs__item--BorderWidth) 0
+ var(--pf-c-tabs__item--BorderWidth) 0;
+ }
+`;
+
+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';
+ }
+ // if 'ok' and 'changed' are both true, show 'changed'
+ 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(() => {
+ setHostStatus(processEventStatus(hostEvent));
+ }, []);
+
+ const handleTabClick = (event, tabIndex) => {
+ setActiveTabKey(tabIndex);
+ };
+
+ const jsonObj = processCodeMirrorValue(hostEvent.event_data.res);
+ const stdErr = processCodeMirrorValue(hostEvent.event_data.res.stderr);
+ const stdOut = processCodeMirrorValue(processStdOutValue(hostEvent));
+
+ return (
+
+ {i18n._(t`Close`)}
+ ,
+ ]}
+ >
+
+
+
+
+ {hostStatus ? : null}
+ {hostEvent.host_name}
+
+ }
+ />
+
+
+
+
+
+
+
+ {activeTabKey === 1 && jsonObj ? (
+ {}}
+ rows={20}
+ hasErrors={false}
+ />
+ ) : (
+
+ )}
+
+
+ {activeTabKey === 2 && stdOut ? (
+ {}}
+ rows={20}
+ hasErrors={false}
+ />
+ ) : (
+
+ )}
+
+
+ {activeTabKey === 3 && stdErr ? (
+ {}}
+ value={stdErr}
+ hasErrors={false}
+ rows={20}
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+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..c67640d7ce
--- /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('StatusIcon');
+ 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('StatusIcon');
+ 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('StatusIcon');
+ 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('StatusIcon');
+ 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/JobEvent.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
index 4e41d50a90..769ccf42a4 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
@@ -77,6 +77,8 @@ function JobEvent({
counter,
created,
event,
+ isClickable,
+ onJobEventClick,
stdout,
start_line,
style,
@@ -88,8 +90,10 @@ function JobEvent({
({ lineNumber, html }) =>
lineNumber >= 0 && (
{lineNumber}
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
index 18508c1372..bde17d7b0d 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
@@ -16,6 +16,7 @@ import ContentLoading from '@components/ContentLoading';
import JobEvent from './JobEvent';
import JobEventSkeleton from './JobEventSkeleton';
import MenuControls from './MenuControls';
+import HostEventModal from './HostEventModal';
const OutputHeader = styled.div`
font-weight: var(--pf-global--FontWeight--bold);
@@ -59,6 +60,8 @@ class JobOutput extends Component {
results: {},
currentlyLoading: [],
remoteRowCount: 0,
+ isHostModalOpen: false,
+ hostEvent: {},
};
this.cache = new CellMeasurerCache({
@@ -69,6 +72,8 @@ class JobOutput extends Component {
this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this);
this.rowRenderer = this.rowRenderer.bind(this);
+ this.handleHostEventClick = this.handleHostEventClick.bind(this);
+ this.handleHostModalClose = this.handleHostModalClose.bind(this);
this.handleScrollFirst = this.handleScrollFirst.bind(this);
this.handleScrollLast = this.handleScrollLast.bind(this);
this.handleScrollNext = this.handleScrollNext.bind(this);
@@ -150,8 +155,39 @@ class JobOutput extends Component {
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;
+ };
+
return (
{results[index] ? (
-
+ this.handleHostEventClick(results[index])}
+ className="row"
+ style={style}
+ {...results[index]}
+ />
) : (
;
@@ -254,6 +302,13 @@ class JobOutput extends Component {
return (
+ {isHostModalOpen && (
+
+ )}
{job.name}
(props.isClickable ? 'pointer' : 'default')};
}
&:hover div {