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..52297e50de
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/JobOutput/HostEventModal.jsx
@@ -0,0 +1,218 @@
+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 { DetailList, Detail } from '@components/DetailList';
+import { HostStatusIcon } 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;
+ .pf-c-modal-box__body {
+ overflow-y: hidden;
+ }
+ .pf-c-tab-content {
+ padding: 24px 0;
+ }
+`;
+
+const HostNameDetailValue = styled.div`
+ align-items: center;
+ display: inline-grid;
+ grid-gap: 10px;
+ grid-template-columns: min-content auto;
+`;
+
+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;
+ }
+`;
+
+function HostEventModal({ handleClose, hostEvent, isOpen, i18n }) {
+ const [hostStatus, setHostStatus] = useState(null);
+ const [activeTabKey, setActiveTabKey] = useState(0);
+
+ useEffect(() => {
+ 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();
+
+ return (
+
+ {i18n._(t`Close`)}
+ ,
+ ]}
+ >
+
+
+
+
+ {hostStatus && }
+ {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);
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..d5c928a99c 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 {