From 474a2a48bbc33a5d6c92e694dec4c93131969627 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 12 Jul 2019 16:16:55 -0400 Subject: [PATCH] add job event component and sanitized html building for output lines --- awx/ui_next/package.json | 3 + awx/ui_next/src/api/models/Jobs.js | 12 +- .../src/screens/Job/JobOutput/JobEvent.jsx | 151 ++++++++++++++++++ .../screens/Job/JobOutput/JobEvent.test.jsx | 72 +++++++++ .../src/screens/Job/JobOutput/JobOutput.jsx | 55 +++++-- 5 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx create mode 100644 awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx 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 { ); }} + );