1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 06:51:10 +03:00

add job event component and sanitized html building for output lines

This commit is contained in:
Jake McDermott 2019-07-12 16:16:55 -04:00 committed by Marliana Lara
parent da92889323
commit 474a2a48bb
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
5 changed files with 273 additions and 20 deletions

View File

@ -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",

View File

@ -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 });
}
}

View File

@ -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 += `<span class="time">${time}</span>`;
}
return {
lineNumber: start_line + index,
html,
};
});
}
function JobEvent({ created, event, stdout, start_line, ...rest }) {
return !stdout ? null : (
<JobEventWrapper {...rest}>
{getLineTextHtml({ created, event, start_line, stdout }).map(
({ lineNumber, html }) =>
lineNumber > 0 && (
<JobEventLine key={lineNumber} isFirst={lineNumber === 1}>
<JobEventLineToggle />
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
<JobEventLineText
dangerouslySetInnerHTML={{
__html: html,
}}
/>
</JobEventLine>
)
)}
</JobEventWrapper>
);
}
export default JobEvent;

View File

@ -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('<JobEvent />', () => {
test('initially renders successfully', () => {
mountWithContexts(<JobEvent {...mockOnPlayStartEvent} />);
});
test('playbook event timestamps are rendered', () => {
let wrapper = mountWithContexts(<JobEvent {...mockOnPlayStartEvent} />);
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(<JobEvent {...singleDigitTimestampEvent} />);
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(<JobEvent {...mockRunnerOnOkEvent} />);
const lineText = wrapper.find(selectors.lineText);
expect(
lineText
.html()
.includes('<span style="color:#080">ok: [localhost]</span>')
).toBe(true);
});
test("events without stdout aren't rendered", () => {
const missingStdoutEvent = { ...mockOnPlayStartEvent };
delete missingStdoutEvent.stdout;
const wrapper = mountWithContexts(<JobEvent {...missingStdoutEvent} />);
expect(wrapper.find(selectors.lineText)).toHaveLength(0);
});
});

View File

@ -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 (
<OutputRow key={key} style={style} className="row">
<div className="id">{results[index].id}</div>
<div className="content">{results[index].stdout}</div>
</OutputRow>
<JobEvent
className="row"
key={key}
style={style}
created={created}
event={event}
start_line={start_line}
stdout={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 {
);
}}
</AutoSizer>
<OutputFooter />
</OutputWrapper>
</CardBody>
);