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: "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('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 {