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:
parent
da92889323
commit
474a2a48bb
@ -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",
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
151
awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
Normal file
151
awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
Normal 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;
|
72
awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
Normal file
72
awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user