mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 17:37:37 -02:30
add job event component and sanitized html building for output lines
This commit is contained in:
committed by
Marliana Lara
parent
da92889323
commit
474a2a48bb
@@ -61,9 +61,12 @@
|
|||||||
"@patternfly/react-core": "^3.16.14",
|
"@patternfly/react-core": "^3.16.14",
|
||||||
"@patternfly/react-icons": "^3.7.5",
|
"@patternfly/react-icons": "^3.7.5",
|
||||||
"@patternfly/react-tokens": "^2.3.3",
|
"@patternfly/react-tokens": "^2.3.3",
|
||||||
|
"ansi-to-html": "^0.6.11",
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
"codemirror": "^5.47.0",
|
"codemirror": "^5.47.0",
|
||||||
"formik": "^1.5.1",
|
"formik": "^1.5.1",
|
||||||
|
"has-ansi": "^3.0.0",
|
||||||
|
"html-entities": "^1.2.1",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"prop-types": "^15.6.2",
|
"prop-types": "^15.6.2",
|
||||||
"react": "^16.4.1",
|
"react": "^16.4.1",
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ class Jobs extends Base {
|
|||||||
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
|
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readJobEvents(id, params = {}) {
|
readEvents(id, jobType = 'job', params = {}) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/job_events/`, {
|
let endpoint;
|
||||||
params,
|
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 React, { Component } from 'react';
|
||||||
import { CardBody } from '@patternfly/react-core';
|
import { CardBody } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { JobsAPI } from '@api';
|
import { JobsAPI } from '@api';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import JobEvent from './JobEvent';
|
||||||
import MenuControls from './shared/MenuControls';
|
import MenuControls from './shared/MenuControls';
|
||||||
import { List, AutoSizer } from 'react-virtualized';
|
|
||||||
|
|
||||||
const OutputToolbar = styled.div`
|
const OutputToolbar = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -15,9 +18,17 @@ const OutputWrapper = styled.div`
|
|||||||
height: calc(100vh - 325px);
|
height: calc(100vh - 325px);
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
`;
|
font-family: monospace;
|
||||||
const OutputRow = styled.div`
|
font-size: 15px;
|
||||||
|
border: 1px solid #b7b7b7;
|
||||||
display: flex;
|
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 {
|
class JobOutput extends Component {
|
||||||
@@ -41,6 +52,7 @@ class JobOutput extends Component {
|
|||||||
this.handleScrollBottom = this.handleScrollBottom.bind(this);
|
this.handleScrollBottom = this.handleScrollBottom.bind(this);
|
||||||
this.handleScrollNext = this.handleScrollNext.bind(this);
|
this.handleScrollNext = this.handleScrollNext.bind(this);
|
||||||
this.handleScrollPrevious = this.handleScrollPrevious.bind(this);
|
this.handleScrollPrevious = this.handleScrollPrevious.bind(this);
|
||||||
|
this.onRowsRendered = this.onRowsRendered.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -50,11 +62,15 @@ class JobOutput extends Component {
|
|||||||
async loadJobEvents() {
|
async loadJobEvents() {
|
||||||
const { job } = this.props;
|
const { job } = this.props;
|
||||||
|
|
||||||
|
this.setState({ hasContentLoading: true });
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: { results = [] },
|
data: { results = [] },
|
||||||
} = await JobsAPI.readJobEvents(job.id);
|
} = await JobsAPI.readEvents(job.id, job.type, {
|
||||||
this.setState({ results, hasContentLoading: true });
|
page_size: 200,
|
||||||
|
order_by: 'start_line',
|
||||||
|
});
|
||||||
|
this.setState({ results });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ contentError: err });
|
this.setState({ contentError: err });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,17 +80,23 @@ class JobOutput extends Component {
|
|||||||
|
|
||||||
renderRow({ index, key, style }) {
|
renderRow({ index, key, style }) {
|
||||||
const { results } = this.state;
|
const { results } = this.state;
|
||||||
|
const { created, event, stdout, start_line } = results[index];
|
||||||
return (
|
return (
|
||||||
<OutputRow key={key} style={style} className="row">
|
<JobEvent
|
||||||
<div className="id">{results[index].id}</div>
|
className="row"
|
||||||
<div className="content">{results[index].stdout}</div>
|
key={key}
|
||||||
</OutputRow>
|
style={style}
|
||||||
|
created={created}
|
||||||
|
event={event}
|
||||||
|
start_line={start_line}
|
||||||
|
stdout={stdout}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onRowsRendered = ({ startIndex, stopIndex }) => {
|
onRowsRendered({ startIndex, stopIndex }) {
|
||||||
this.setState({ startIndex, stopIndex });
|
this.setState({ startIndex, stopIndex });
|
||||||
};
|
}
|
||||||
|
|
||||||
handleScrollPrevious() {
|
handleScrollPrevious() {
|
||||||
const { startIndex, stopIndex } = this.state;
|
const { startIndex, stopIndex } = this.state;
|
||||||
@@ -84,7 +106,7 @@ class JobOutput extends Component {
|
|||||||
|
|
||||||
handleScrollNext() {
|
handleScrollNext() {
|
||||||
const { stopIndex } = this.state;
|
const { stopIndex } = this.state;
|
||||||
this.setState({ scrollToIndex: stopIndex + 1});
|
this.setState({ scrollToIndex: stopIndex + 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScrollTop() {
|
handleScrollTop() {
|
||||||
@@ -104,7 +126,7 @@ class JobOutput extends Component {
|
|||||||
contentError,
|
contentError,
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
startIndex,
|
startIndex,
|
||||||
stopIndex
|
stopIndex,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
@@ -137,10 +159,10 @@ class JobOutput extends Component {
|
|||||||
ref={this.listRef}
|
ref={this.listRef}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
rowHeight={50}
|
rowHeight={25}
|
||||||
rowRenderer={this.renderRow}
|
rowRenderer={this.renderRow}
|
||||||
rowCount={results.length}
|
rowCount={results.length}
|
||||||
overscanRowCount={5}
|
overscanRowCount={50}
|
||||||
scrollToIndex={scrollToIndex}
|
scrollToIndex={scrollToIndex}
|
||||||
onRowsRendered={this.onRowsRendered}
|
onRowsRendered={this.onRowsRendered}
|
||||||
scrollToAlignment="start"
|
scrollToAlignment="start"
|
||||||
@@ -148,6 +170,7 @@ class JobOutput extends Component {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
|
<OutputFooter />
|
||||||
</OutputWrapper>
|
</OutputWrapper>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user