diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json
index 48d0855cda..9a67f2ef02 100644
--- a/awx/ui_next/package.json
+++ b/awx/ui_next/package.json
@@ -61,9 +61,12 @@
"@patternfly/react-core": "^3.16.14",
"@patternfly/react-icons": "^3.7.5",
"@patternfly/react-tokens": "^2.3.3",
+ "ansi-to-html": "^0.6.11",
"axios": "^0.18.0",
"codemirror": "^5.47.0",
"formik": "^1.5.1",
+ "has-ansi": "^3.0.0",
+ "html-entities": "^1.2.1",
"js-yaml": "^3.13.1",
"prop-types": "^15.6.2",
"react": "^16.4.1",
diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js
index a47b939a41..1a01ace0ba 100644
--- a/awx/ui_next/src/api/models/Jobs.js
+++ b/awx/ui_next/src/api/models/Jobs.js
@@ -19,10 +19,14 @@ class Jobs extends Base {
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
}
- readJobEvents(id, params = {}) {
- return this.http.get(`${this.baseUrl}${id}/job_events/`, {
- params,
- });
+ readEvents(id, jobType = 'job', params = {}) {
+ let endpoint;
+ if (jobType === 'job') {
+ endpoint = `${this.baseUrl}${id}/job_events/`;
+ } else {
+ endpoint = `${this.baseUrl}${id}/events/`;
+ }
+ return this.http.get(endpoint, { params });
}
}
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
new file mode 100644
index 0000000000..63d7b80c1c
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
@@ -0,0 +1,151 @@
+import Ansi from 'ansi-to-html';
+import hasAnsi from 'has-ansi';
+import Entities from 'html-entities';
+import styled from 'styled-components';
+import React from 'react';
+
+const EVENT_START_TASK = 'playbook_on_task_start';
+const EVENT_START_PLAY = 'playbook_on_play_start';
+const EVENT_STATS_PLAY = 'playbook_on_stats';
+const TIME_EVENTS = [EVENT_START_TASK, EVENT_START_PLAY, EVENT_STATS_PLAY];
+
+const ansi = new Ansi({
+ stream: true,
+ colors: {
+ 0: '#000',
+ 1: '#A00',
+ 2: '#080',
+ 3: '#F0AD4E',
+ 4: '#00A',
+ 5: '#A0A',
+ 6: '#0AA',
+ 7: '#AAA',
+ 8: '#555',
+ 9: '#F55',
+ 10: '#5F5',
+ 11: '#FF5',
+ 12: '#55F',
+ 13: '#F5F',
+ 14: '#5FF',
+ 15: '#FFF',
+ },
+});
+const entities = new Entities.AllHtmlEntities();
+
+const JobEventWrapper = styled.div``;
+const JobEventLine = styled.div`
+ display: flex;
+
+ &:hover {
+ background-color: white;
+ }
+
+ &:hover div {
+ background-color: white;
+ }
+
+ &--hidden {
+ display: none;
+ }
+ ${({ isFirst }) => (isFirst ? 'padding-top: 10px;' : '')}
+`;
+const JobEventLineToggle = styled.div`
+ background-color: #ebebeb;
+ color: #646972;
+ display: flex;
+ flex: 0 0 30px;
+ font-size: 18px;
+ justify-content: center;
+ line-height: 12px;
+
+ & > i {
+ cursor: pointer;
+ }
+
+ user-select: none;
+`;
+const JobEventLineNumber = styled.div`
+ color: #161b1f;
+ background-color: #ebebeb;
+ flex: 0 0 45px;
+ text-align: right;
+ vertical-align: top;
+ padding-right: 5px;
+ border-right: 1px solid #b7b7b7;
+ user-select: none;
+`;
+const JobEventLineText = styled.div`
+ padding: 0 15px;
+ white-space: pre-wrap;
+ word-break: break-all;
+ word-wrap: break-word;
+
+ .time {
+ font-size: 14px;
+ font-weight: 600;
+ user-select: none;
+ background-color: #ebebeb;
+ border-radius: 12px;
+ padding: 2px 10px;
+ margin-left: 15px;
+ }
+`;
+
+function getTimestamp({ created }) {
+ const date = new Date(created);
+
+ const dateHours = date.getHours();
+ const dateMinutes = date.getMinutes();
+ const dateSeconds = date.getSeconds();
+
+ const stampHour = dateHours < 10 ? `0${dateHours}` : dateHours;
+ const stampMinute = dateMinutes < 10 ? `0${dateMinutes}` : dateMinutes;
+ const stampSecond = dateSeconds < 10 ? `0${dateSeconds}` : dateSeconds;
+
+ return `${stampHour}:${stampMinute}:${stampSecond}`;
+}
+
+function getLineTextHtml({ created, event, start_line, stdout }) {
+ const sanitized = entities.encode(stdout);
+ return sanitized.split('\r\n').map((lineText, index) => {
+ let html;
+ if (hasAnsi(lineText)) {
+ html = ansi.toHtml(lineText);
+ } else {
+ html = lineText;
+ }
+
+ if (index === 1 && TIME_EVENTS.includes(event)) {
+ const time = getTimestamp({ created });
+ html += `${time}`;
+ }
+
+ return {
+ lineNumber: start_line + index,
+ html,
+ };
+ });
+}
+
+function JobEvent({ created, event, stdout, start_line, ...rest }) {
+ return !stdout ? null : (
+
+ {getLineTextHtml({ created, event, start_line, stdout }).map(
+ ({ lineNumber, html }) =>
+ lineNumber > 0 && (
+
+
+ {lineNumber}
+
+
+ )
+ )}
+
+ );
+}
+
+export default JobEvent;
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
new file mode 100644
index 0000000000..d413f53726
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+
+import JobEvent from './JobEvent';
+
+const mockOnPlayStartEvent = {
+ created: '2019-07-11T18:11:22.005319Z',
+ event: 'playbook_on_play_start',
+ counter: 2,
+ start_line: 0,
+ end_line: 2,
+ stdout:
+ '\r\nPLAY [add hosts to inventory] **************************************************',
+};
+const mockRunnerOnOkEvent = {
+ created: '2019-07-11T18:09:22.906001Z',
+ event: 'runner_on_ok',
+ counter: 5,
+ start_line: 4,
+ end_line: 5,
+ stdout: '\u001b[0;32mok: [localhost]\u001b[0m',
+};
+const selectors = {
+ lineText: 'JobEvent__JobEventLineText',
+};
+
+function tzHours(hours) {
+ const date = new Date();
+ date.setUTCHours(hours);
+ return date.getHours();
+}
+
+describe('', () => {
+ test('initially renders successfully', () => {
+ mountWithContexts();
+ });
+
+ test('playbook event timestamps are rendered', () => {
+ let wrapper = mountWithContexts();
+ let lineText = wrapper.find(selectors.lineText);
+ expect(
+ lineText.filterWhere(e => e.html().includes(`${tzHours(18)}:11:22`))
+ ).toHaveLength(1);
+
+ const singleDigitTimestampEvent = {
+ ...mockOnPlayStartEvent,
+ created: '2019-07-11T08:01:02.906001Z',
+ };
+ wrapper = mountWithContexts();
+ lineText = wrapper.find(selectors.lineText);
+ expect(
+ lineText.filterWhere(e => e.html().includes(`${tzHours(8)}:01:02`))
+ ).toHaveLength(1);
+ });
+
+ test('ansi stdout colors are rendered as html', () => {
+ const wrapper = mountWithContexts();
+ const lineText = wrapper.find(selectors.lineText);
+ expect(
+ lineText
+ .html()
+ .includes('ok: [localhost]')
+ ).toBe(true);
+ });
+
+ test("events without stdout aren't rendered", () => {
+ const missingStdoutEvent = { ...mockOnPlayStartEvent };
+ delete missingStdoutEvent.stdout;
+ const wrapper = mountWithContexts();
+ expect(wrapper.find(selectors.lineText)).toHaveLength(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
index 6f70d01c78..179bb2927f 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
@@ -1,11 +1,14 @@
+import styled from 'styled-components';
+import { List, AutoSizer } from 'react-virtualized';
+
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
-import styled from 'styled-components';
+
import { JobsAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
+import JobEvent from './JobEvent';
import MenuControls from './shared/MenuControls';
-import { List, AutoSizer } from 'react-virtualized';
const OutputToolbar = styled.div`
display: flex;
@@ -15,9 +18,17 @@ const OutputWrapper = styled.div`
height: calc(100vh - 325px);
background-color: #fafafa;
margin-top: 24px;
-`;
-const OutputRow = styled.div`
+ font-family: monospace;
+ font-size: 15px;
+ border: 1px solid #b7b7b7;
display: flex;
+ flex-direction: column;
+`;
+const OutputFooter = styled.div`
+ background-color: #ebebeb;
+ border-right: 1px solid #b7b7b7;
+ width: 75px;
+ flex: 1;
`;
class JobOutput extends Component {
@@ -41,6 +52,7 @@ class JobOutput extends Component {
this.handleScrollBottom = this.handleScrollBottom.bind(this);
this.handleScrollNext = this.handleScrollNext.bind(this);
this.handleScrollPrevious = this.handleScrollPrevious.bind(this);
+ this.onRowsRendered = this.onRowsRendered.bind(this);
}
componentDidMount() {
@@ -50,11 +62,15 @@ class JobOutput extends Component {
async loadJobEvents() {
const { job } = this.props;
+ this.setState({ hasContentLoading: true });
try {
const {
data: { results = [] },
- } = await JobsAPI.readJobEvents(job.id);
- this.setState({ results, hasContentLoading: true });
+ } = await JobsAPI.readEvents(job.id, job.type, {
+ page_size: 200,
+ order_by: 'start_line',
+ });
+ this.setState({ results });
} catch (err) {
this.setState({ contentError: err });
} finally {
@@ -64,17 +80,23 @@ class JobOutput extends Component {
renderRow({ index, key, style }) {
const { results } = this.state;
+ const { created, event, stdout, start_line } = results[index];
return (
-
- {results[index].id}
- {results[index].stdout}
-
+
);
}
- onRowsRendered = ({ startIndex, stopIndex }) => {
+ onRowsRendered({ startIndex, stopIndex }) {
this.setState({ startIndex, stopIndex });
- };
+ }
handleScrollPrevious() {
const { startIndex, stopIndex } = this.state;
@@ -84,7 +106,7 @@ class JobOutput extends Component {
handleScrollNext() {
const { stopIndex } = this.state;
- this.setState({ scrollToIndex: stopIndex + 1});
+ this.setState({ scrollToIndex: stopIndex + 1 });
}
handleScrollTop() {
@@ -104,7 +126,7 @@ class JobOutput extends Component {
contentError,
scrollToIndex,
startIndex,
- stopIndex
+ stopIndex,
} = this.state;
if (hasContentLoading) {
@@ -137,10 +159,10 @@ class JobOutput extends Component {
ref={this.listRef}
width={width}
height={height}
- rowHeight={50}
+ rowHeight={25}
rowRenderer={this.renderRow}
rowCount={results.length}
- overscanRowCount={5}
+ overscanRowCount={50}
scrollToIndex={scrollToIndex}
onRowsRendered={this.onRowsRendered}
scrollToAlignment="start"
@@ -148,6 +170,7 @@ class JobOutput extends Component {
);
}}
+
);