Merge pull request #4383 from marshmalien/4236-output-toolbar

Job Output - Pagination and Static List

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-08-13 21:38:25 +00:00 committed by GitHub
commit 31308e3795
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 9639 additions and 32 deletions

View File

@ -2307,6 +2307,14 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
},
"ansi-to-html": {
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.11.tgz",
"integrity": "sha512-88XZtrcwrfkyn6fGstHnkaF1kl7hGtNCYh4vSmItgEV+6JnQHryDBf7udF4f2RhTRQmYvJvPcTtqgaqrxzc9oA==",
"requires": {
"entities": "^1.1.1"
}
},
"ansi-wrap": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz",
@ -4242,6 +4250,16 @@
"has-ansi": "^2.0.0",
"strip-ansi": "^3.0.0",
"supports-color": "^2.0.0"
},
"dependencies": {
"has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
"integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
"requires": {
"ansi-regex": "^2.0.0"
}
}
}
},
"chardet": {
@ -4576,6 +4594,11 @@
}
}
},
"clsx": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.4.tgz",
"integrity": "sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg=="
},
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -5429,6 +5452,14 @@
"esutils": "^2.0.2"
}
},
"dom-helpers": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"requires": {
"@babel/runtime": "^7.1.2"
}
},
"dom-serializer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz",
@ -5667,8 +5698,7 @@
"entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"dev": true
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="
},
"enzyme": {
"version": "3.9.0",
@ -7174,7 +7204,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -7195,12 +7226,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -7215,17 +7248,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -7342,7 +7378,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -7354,6 +7391,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -7368,6 +7406,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -7375,12 +7414,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -7399,6 +7440,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -7479,7 +7521,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -7491,6 +7534,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -7576,7 +7620,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -7612,6 +7657,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -7631,6 +7677,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -7674,12 +7721,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@ -7921,11 +7970,18 @@
}
},
"has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
"integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-3.0.0.tgz",
"integrity": "sha1-Ngd+8dFfMzSEqn+neihgbxxlWzc=",
"requires": {
"ansi-regex": "^2.0.0"
"ansi-regex": "^3.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
}
}
},
"has-flag": {
@ -8138,8 +8194,7 @@
"html-entities": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz",
"integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=",
"dev": true
"integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8="
},
"html-tokenize": {
"version": "2.0.0",
@ -10790,6 +10845,11 @@
"type-check": "~0.3.2"
}
},
"linear-layout-vector": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/linear-layout-vector/-/linear-layout-vector-0.0.1.tgz",
"integrity": "sha1-OYEU1zA7bsx/1rJzr3uEAdi6nHA="
},
"load-json-file": {
"version": "1.1.0",
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
@ -13130,8 +13190,7 @@
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-router": {
"version": "4.3.1",
@ -13190,6 +13249,20 @@
}
}
},
"react-virtualized": {
"version": "9.21.1",
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.1.tgz",
"integrity": "sha512-E53vFjRRMCyUTEKuDLuGH1ld/9TFzjf/fFW816PE4HFXWZorESbSTYtiZz1oAjra0MminaUU1EnvUxoGuEFFPA==",
"requires": {
"babel-runtime": "^6.26.0",
"clsx": "^1.0.1",
"dom-helpers": "^2.4.0 || ^3.0.0",
"linear-layout-vector": "0.0.1",
"loose-envify": "^1.3.0",
"prop-types": "^15.6.0",
"react-lifecycles-compat": "^3.0.4"
}
},
"read-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",

View File

@ -5,8 +5,8 @@
"main": "index.jsx",
"scripts": {
"start": "webpack-dev-server --config ./webpack.config.js --mode development",
"test": "jest --coverage",
"test-watch": "jest --watch",
"test": "TZ='UTC' jest --coverage",
"test-watch": "TZ='UTC' jest --watch",
"lint": "eslint --ext .js --ext .jsx .",
"add-locale": "lingui add-locale",
"extract-strings": "lingui extract",
@ -61,15 +61,19 @@
"@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",
"react-codemirror2": "^6.0.0",
"react-dom": "^16.4.1",
"react-router-dom": "^4.3.1",
"react-virtualized": "^9.21.1",
"styled-components": "^4.2.0"
}
}

View File

@ -18,6 +18,16 @@ class Jobs extends Base {
readDetail(id, type) {
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
}
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 });
}
}
export default Jobs;

View File

@ -0,0 +1,109 @@
import Ansi from 'ansi-to-html';
import hasAnsi from 'has-ansi';
import Entities from 'html-entities';
import React from 'react';
import {
JobEventLine,
JobEventLineToggle,
JobEventLineNumber,
JobEventLineText,
} from './shared';
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();
function getTimestamp({ created }) {
const date = new Date(created);
const dateHours = date.getHours();
const dateMinutes = date.getMinutes();
const dateSeconds = date.getSeconds();
const stampHours = dateHours < 10 ? `0${dateHours}` : dateHours;
const stampMinutes = dateMinutes < 10 ? `0${dateMinutes}` : dateMinutes;
const stampSeconds = dateSeconds < 10 ? `0${dateSeconds}` : dateSeconds;
return `${stampHours}:${stampMinutes}:${stampSeconds}`;
}
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({
counter,
created,
event,
stdout,
start_line,
style,
type,
}) {
return !stdout ? null : (
<div style={style} type={type}>
{getLineTextHtml({ created, event, start_line, stdout }).map(
({ lineNumber, html }) =>
lineNumber >= 0 && (
<JobEventLine
key={`${counter}-${lineNumber}`}
isFirst={lineNumber === 0}
>
<JobEventLineToggle />
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
<JobEventLineText
type="job_event_line_text"
dangerouslySetInnerHTML={{
__html: html,
}}
/>
</JobEventLine>
)
)}
</div>
);
}
export default JobEvent;

View File

@ -0,0 +1,66 @@
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: 'JobEventLineText',
};
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.text().includes('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.text().includes('08: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

@ -0,0 +1,31 @@
import React from 'react';
import {
JobEventLine,
JobEventLineToggle,
JobEventLineNumber,
JobEventLineText,
} from './shared';
function JobEventSkeletonContent({ contentLength }) {
return (
<JobEventLineText>
<span className="content">{' '.repeat(contentLength)}</span>
</JobEventLineText>
);
}
function JobEventSkeleton({ counter, contentLength, style }) {
return (
counter > 1 && (
<div style={style}>
<JobEventLine key={counter}>
<JobEventLineToggle />
<JobEventLineNumber />
<JobEventSkeletonContent contentLength={contentLength} />
</JobEventLine>
</div>
)
);
}
export default JobEventSkeleton;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import JobEventSkeleton from './JobEventSkeleton';
const contentSelector = 'JobEventSkeletonContent';
describe('<JobEvenSkeletont />', () => {
test('initially renders successfully', () => {
const wrapper = mountWithContexts(
<JobEventSkeleton contentLength={80} counter={100} />
);
expect(wrapper.find(contentSelector).length).toEqual(1);
});
test('always skips first counter', () => {
const wrapper = mountWithContexts(
<JobEventSkeleton contentLength={80} counter={1} />
);
expect(wrapper.find(contentSelector).length).toEqual(0);
});
});

View File

@ -1,13 +1,300 @@
import styled from 'styled-components';
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
InfiniteLoader,
List,
} from 'react-virtualized';
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { JobsAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import JobEvent from './JobEvent';
import JobEventSkeleton from './JobEventSkeleton';
import MenuControls from './MenuControls';
const OutputHeader = styled.div`
font-weight: var(--pf-global--FontWeight--bold);
`;
const OutputToolbar = styled.div`
display: flex;
justify-content: flex-end;
`;
const OutputWrapper = styled.div`
height: calc(100vh - 350px);
background-color: #fafafa;
margin-top: 24px;
font-family: monospace;
font-size: 15px;
outline: 1px solid #d7d7d7;
display: flex;
flex-direction: column;
`;
const OutputFooter = styled.div`
background-color: #ebebeb;
border-right: 1px solid #d7d7d7;
width: 75px;
flex: 1;
`;
function range(low, high) {
const numbers = [];
for (let n = low; n <= high; n++) {
numbers.push(n);
}
return numbers;
}
class JobOutput extends Component {
constructor(props) {
super(props);
this.listRef = React.createRef();
this.state = {
contentError: null,
hasContentLoading: true,
results: {},
currentlyLoading: [],
remoteRowCount: 0,
};
this.cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 25,
});
this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this);
this.rowRenderer = this.rowRenderer.bind(this);
this.handleScrollFirst = this.handleScrollFirst.bind(this);
this.handleScrollLast = this.handleScrollLast.bind(this);
this.handleScrollNext = this.handleScrollNext.bind(this);
this.handleScrollPrevious = this.handleScrollPrevious.bind(this);
this.handleResize = this.handleResize.bind(this);
this.isRowLoaded = this.isRowLoaded.bind(this);
this.loadMoreRows = this.loadMoreRows.bind(this);
this.scrollToRow = this.scrollToRow.bind(this);
}
componentDidMount() {
this._isMounted = true;
this.loadJobEvents();
}
componentDidUpdate(prevProps, prevState) {
// recompute row heights for any job events that have transitioned
// from loading to loaded
const { currentlyLoading } = this.state;
let shouldRecomputeRowHeights = false;
prevState.currentlyLoading
.filter(n => !currentlyLoading.includes(n))
.forEach(n => {
shouldRecomputeRowHeights = true;
this.cache.clear(n);
});
if (shouldRecomputeRowHeights) {
if (this.listRef.recomputeRowHeights) {
this.listRef.recomputeRowHeights();
}
}
}
componentWillUnmount() {
this._isMounted = false;
}
async loadJobEvents() {
const { job } = this.props;
const loadRange = range(1, 50);
this._isMounted &&
this.setState(({ currentlyLoading }) => ({
hasContentLoading: true,
currentlyLoading: currentlyLoading.concat(loadRange),
}));
try {
const {
data: { results: newResults = [], count },
} = await JobsAPI.readEvents(job.id, job.type, {
page_size: 50,
order_by: 'start_line',
});
this._isMounted &&
this.setState(({ results }) => {
newResults.forEach(jobEvent => {
results[jobEvent.counter] = jobEvent;
});
return { results, remoteRowCount: count + 1 };
});
} catch (err) {
this.setState({ contentError: err });
} finally {
this._isMounted &&
this.setState(({ currentlyLoading }) => ({
hasContentLoading: false,
currentlyLoading: currentlyLoading.filter(
n => !loadRange.includes(n)
),
}));
}
}
isRowLoaded({ index }) {
const { results, currentlyLoading } = this.state;
if (results[index]) {
return true;
}
return currentlyLoading.includes(index);
}
rowRenderer({ index, parent, key, style }) {
const { results } = this.state;
return (
<CellMeasurer
key={key}
cache={this.cache}
parent={parent}
rowIndex={index}
columnIndex={0}
>
{results[index] ? (
<JobEvent className="row" style={style} {...results[index]} />
) : (
<JobEventSkeleton
className="row"
style={style}
counter={index}
contentLength={80}
/>
)}
</CellMeasurer>
);
}
loadMoreRows({ startIndex, stopIndex }) {
if (startIndex === 0 && stopIndex === 0) {
return Promise.resolve(null);
}
const { job } = this.props;
const loadRange = range(startIndex, stopIndex);
this._isMounted &&
this.setState(({ currentlyLoading }) => ({
currentlyLoading: currentlyLoading.concat(loadRange),
}));
const params = {
counter__gte: startIndex,
counter__lte: stopIndex,
order_by: 'start_line',
};
return JobsAPI.readEvents(job.id, job.type, params).then(response => {
this._isMounted &&
this.setState(({ results, currentlyLoading }) => {
response.data.results.forEach(jobEvent => {
results[jobEvent.counter] = jobEvent;
});
return {
results,
currentlyLoading: currentlyLoading.filter(
n => !loadRange.includes(n)
),
};
});
});
}
scrollToRow(rowIndex) {
this.listRef.scrollToRow(rowIndex);
}
handleScrollPrevious() {
const startIndex = this.listRef.Grid._renderedRowStartIndex;
const stopIndex = this.listRef.Grid._renderedRowStopIndex;
const scrollRange = stopIndex - startIndex + 1;
this.scrollToRow(Math.max(0, startIndex - scrollRange));
}
handleScrollNext() {
const stopIndex = this.listRef.Grid._renderedRowStopIndex;
this.scrollToRow(stopIndex - 1);
}
handleScrollFirst() {
this.scrollToRow(0);
}
handleScrollLast() {
const { remoteRowCount } = this.state;
this.scrollToRow(remoteRowCount - 1);
}
handleResize({ width }) {
if (width !== this._previousWidth) {
this.cache.clearAll();
this.listRef.recomputeRowHeights();
}
this._previousWidth = width;
}
render() {
const { job } = this.props;
const { hasContentLoading, contentError, remoteRowCount } = this.state;
if (hasContentLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<CardBody>
<b>{job.name}</b>
<OutputHeader>{job.name}</OutputHeader>
<OutputToolbar>
<MenuControls
onScrollFirst={this.handleScrollFirst}
onScrollLast={this.handleScrollLast}
onScrollNext={this.handleScrollNext}
onScrollPrevious={this.handleScrollPrevious}
/>
</OutputToolbar>
<OutputWrapper>
<InfiniteLoader
isRowLoaded={this.isRowLoaded}
loadMoreRows={this.loadMoreRows}
rowCount={remoteRowCount}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer onResize={this.handleResize}>
{({ width, height }) => {
return (
<List
ref={ref => {
this.listRef = ref;
registerChild(ref);
}}
deferredMeasurementCache={this.cache}
height={height || 1}
onRowsRendered={onRowsRendered}
rowCount={remoteRowCount}
rowHeight={this.cache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
width={width || 1}
overscanRowCount={20}
/>
);
}}
</AutoSizer>
)}
</InfiniteLoader>
<OutputFooter />
</OutputWrapper>
</CardBody>
);
}

View File

@ -1,15 +1,196 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobOutput from './JobOutput';
import { JobsAPI } from '@api';
import mockJobData from './data.job.json';
import mockJobEventsData from './data.job_events.json';
jest.mock('@api');
async function checkOutput(wrapper, expectedLines) {
await waitForElement(wrapper, 'div[type="job_event"]', el => el.length > 1);
const jobEventLines = wrapper.find('div[type="job_event_line_text"]');
const actualLines = [];
jobEventLines.forEach(line => {
actualLines.push(line.text());
});
expect(actualLines.length).toEqual(expectedLines.length);
expectedLines.forEach((line, index) => {
expect(actualLines[index]).toEqual(line);
});
}
async function findScrollButtons(wrapper) {
const menuControls = await waitForElement(wrapper, 'MenuControls');
const scrollFirstButton = menuControls.find(
'button[aria-label="scroll first"]'
);
const scrollLastButton = menuControls.find(
'button[aria-label="scroll last"]'
);
const scrollPreviousButton = menuControls.find(
'button[aria-label="scroll previous"]'
);
return {
scrollFirstButton,
scrollLastButton,
scrollPreviousButton,
};
}
describe('<JobOutput />', () => {
const mockDetails = {
name: 'Foo',
};
let wrapper;
const mockJob = mockJobData;
const mockJobEvents = mockJobEventsData;
const scrollMock = jest.fn();
test('initially renders succesfully', () => {
mountWithContexts(<JobOutput job={mockDetails} />);
beforeEach(() => {
JobsAPI.readEvents.mockResolvedValue({
data: {
count: 100,
next: null,
previous: null,
results: mockJobEvents.results,
},
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders succesfully', async done => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
await checkOutput(wrapper, [
'',
'PLAY [all] *********************************************************************15:37:25',
'',
'TASK [debug] *******************************************************************15:37:25',
'ok: [localhost] => (item=1) => {',
' "msg": "This is a debug message: 1"',
'}',
'ok: [localhost] => (item=2) => {',
' "msg": "This is a debug message: 2"',
'}',
'ok: [localhost] => (item=3) => {',
' "msg": "This is a debug message: 3"',
'}',
'ok: [localhost] => (item=4) => {',
' "msg": "This is a debug message: 4"',
'}',
'ok: [localhost] => (item=5) => {',
' "msg": "This is a debug message: 5"',
'}',
'ok: [localhost] => (item=6) => {',
' "msg": "This is a debug message: 6"',
'}',
'ok: [localhost] => (item=7) => {',
' "msg": "This is a debug message: 7"',
'}',
'ok: [localhost] => (item=8) => {',
' "msg": "This is a debug message: 8"',
'}',
'ok: [localhost] => (item=9) => {',
' "msg": "This is a debug message: 9"',
'}',
'ok: [localhost] => (item=10) => {',
' "msg": "This is a debug message: 10"',
'}',
'ok: [localhost] => (item=11) => {',
' "msg": "This is a debug message: 11"',
'}',
'ok: [localhost] => (item=12) => {',
' "msg": "This is a debug message: 12"',
'}',
'ok: [localhost] => (item=13) => {',
' "msg": "This is a debug message: 13"',
'}',
'ok: [localhost] => (item=14) => {',
' "msg": "This is a debug message: 14"',
'}',
'ok: [localhost] => (item=15) => {',
' "msg": "This is a debug message: 15"',
'}',
'ok: [localhost] => (item=16) => {',
' "msg": "This is a debug message: 16"',
'}',
]);
expect(wrapper.find('JobOutput').length).toBe(1);
done();
});
test('should call scrollToRow with expected index when scroll "previous" button is clicked', async done => {
const handleScrollPrevious = jest.spyOn(
JobOutput.prototype,
'handleScrollPrevious'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
const { scrollLastButton, scrollPreviousButton } = await findScrollButtons(
wrapper
);
wrapper.find('JobOutput').instance().scrollToRow = scrollMock;
scrollLastButton.simulate('click');
scrollPreviousButton.simulate('click');
expect(handleScrollPrevious).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(2);
expect(scrollMock.mock.calls).toEqual([[100], [0]]);
done();
});
test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async done => {
const handleScrollFirst = jest.spyOn(
JobOutput.prototype,
'handleScrollFirst'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
const { scrollFirstButton, scrollLastButton } = await findScrollButtons(
wrapper
);
wrapper.find('JobOutput').instance().scrollToRow = scrollMock;
scrollFirstButton.simulate('click');
scrollLastButton.simulate('click');
scrollFirstButton.simulate('click');
expect(handleScrollFirst).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(3);
expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]);
done();
});
test('should call scrollToRow with expected index on when scroll "last" button is clicked', async done => {
const handleScrollLast = jest.spyOn(
JobOutput.prototype,
'handleScrollLast'
);
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper
.find('JobOutput')
.instance()
.handleResize({ width: 100 });
const { scrollLastButton } = await findScrollButtons(wrapper);
wrapper.find('JobOutput').instance().scrollToRow = scrollMock;
scrollLastButton.simulate('click');
expect(handleScrollLast).toHaveBeenCalled();
expect(scrollMock).toHaveBeenCalledTimes(1);
expect(scrollMock.mock.calls).toEqual([[100]]);
done();
});
test('should throw error', async done => {
JobsAPI.readEvents = () => Promise.reject(new Error());
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Button as PFButton } from '@patternfly/react-core';
import {
PlusIcon,
AngleDoubleUpIcon,
AngleDoubleDownIcon,
AngleUpIcon,
AngleDownIcon,
} from '@patternfly/react-icons';
import styled from 'styled-components';
const Wrapper = styled.div`
display: grid;
grid-gap: 20px;
grid-template-columns: repeat(5, 1fr);
`;
const Button = styled(PFButton)`
&:hover {
background-color: #0066cc;
color: white;
}
`;
const MenuControls = ({
onScrollFirst,
onScrollLast,
onScrollNext,
onScrollPrevious,
}) => (
<Wrapper>
<Button variant="plain">
<PlusIcon />
</Button>
<Button
aria-label="scroll previous"
onClick={onScrollPrevious}
variant="plain"
>
<AngleUpIcon />
</Button>
<Button aria-label="scroll next" onClick={onScrollNext} variant="plain">
<AngleDownIcon />
</Button>
<Button aria-label="scroll first" onClick={onScrollFirst} variant="plain">
<AngleDoubleUpIcon />
</Button>
<Button aria-label="scroll last" onClick={onScrollLast} variant="plain">
<AngleDoubleDownIcon />
</Button>
</Wrapper>
);
export default MenuControls;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { mount } from 'enzyme';
import MenuControls from './MenuControls';
let wrapper;
let PlusIcon;
let AngleDoubleUpIcon;
let AngleDoubleDownIcon;
let AngleUpIcon;
let AngleDownIcon;
const findChildren = () => {
PlusIcon = wrapper.find('PlusIcon');
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
AngleUpIcon = wrapper.find('AngleUpIcon');
AngleDownIcon = wrapper.find('AngleDownIcon');
};
describe('MenuControls', () => {
test('should render successfully', () => {
wrapper = mount(<MenuControls />);
expect(wrapper).toHaveLength(1);
});
test('should render menu control icons', () => {
wrapper = mount(<MenuControls />);
findChildren();
expect(PlusIcon.length).toBe(1);
expect(AngleDoubleUpIcon.length).toBe(1);
expect(AngleDoubleDownIcon.length).toBe(1);
expect(AngleUpIcon.length).toBe(1);
expect(AngleDownIcon.length).toBe(1);
});
});

View File

@ -0,0 +1,194 @@
{
"id": 2,
"type": "job",
"url": "/api/v2/jobs/2/",
"related": {
"created_by": "/api/v2/users/1/",
"labels": "/api/v2/jobs/2/labels/",
"inventory": "/api/v2/inventories/1/",
"project": "/api/v2/projects/6/",
"extra_credentials": "/api/v2/jobs/2/extra_credentials/",
"credentials": "/api/v2/jobs/2/credentials/",
"unified_job_template": "/api/v2/job_templates/7/",
"stdout": "/api/v2/jobs/2/stdout/",
"job_events": "/api/v2/jobs/2/job_events/",
"job_host_summaries": "/api/v2/jobs/2/job_host_summaries/",
"activity_stream": "/api/v2/jobs/2/activity_stream/",
"notifications": "/api/v2/jobs/2/notifications/",
"create_schedule": "/api/v2/jobs/2/create_schedule/",
"job_template": "/api/v2/job_templates/7/",
"cancel": "/api/v2/jobs/2/cancel/",
"project_update": "/api/v2/project_updates/4/",
"relaunch": "/api/v2/jobs/2/relaunch/"
},
"summary_fields": {
"inventory": {
"id": 1,
"name": "Demo Inventory",
"description": "",
"has_active_failures": false,
"total_hosts": 1,
"hosts_with_active_failures": 0,
"total_groups": 0,
"groups_with_active_failures": 0,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 1,
"kind": ""
},
"project": {
"id": 6,
"name": "Demo Project",
"description": "",
"status": "successful",
"scm_type": "git"
},
"project_update": {
"id": 4,
"name": "Demo Project",
"description": "",
"status": "successful",
"failed": false
},
"job_template": {
"id": 7,
"name": "Demo Job Template",
"description": ""
},
"unified_job_template": {
"id": 7,
"name": "Demo Job Template",
"description": "",
"unified_job_type": "job"
},
"instance_group": {
"id": 1,
"name": "tower"
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"delete": true,
"start": true
},
"labels": {
"count": 0,
"results": []
},
"extra_credentials": [],
"credentials": [
{
"id": 1,
"name": "Demo Credential",
"description": "",
"kind": "ssh",
"cloud": false
}
]
},
"created": "2019-08-08T19:24:05.344276Z",
"modified": "2019-08-08T19:24:18.162949Z",
"name": "Demo Job Template",
"description": "",
"job_type": "run",
"inventory": 1,
"project": 6,
"playbook": "chatty_tasks.yml",
"forks": 0,
"limit": "",
"verbosity": 0,
"extra_vars": "{\"num_messages\": 94}",
"job_tags": "",
"force_handlers": false,
"skip_tags": "",
"start_at_task": "",
"timeout": 0,
"use_fact_cache": false,
"unified_job_template": 7,
"launch_type": "manual",
"status": "successful",
"failed": false,
"started": "2019-08-08T19:24:18.329589Z",
"finished": "2019-08-08T19:24:50.119995Z",
"elapsed": 31.79,
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
"job_cwd": "/projects/_6__demo_project",
"job_env": {
"HOSTNAME": "awx",
"MAKEFLAGS": "w",
"RABBITMQ_USER": "guest",
"OS": "Operating System: Docker for Mac",
"LC_ALL": "en_US.UTF-8",
"RABBITMQ_VHOST": "/",
"SDB_HOST": "0.0.0.0",
"MAKELEVEL": "2",
"VIRTUAL_ENV": "/venv/ansible",
"MFLAGS": "-w",
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"RABBITMQ_PASS": "**********",
"SUPERVISOR_GROUP_NAME": "tower-processes",
"PWD": "/awx_devel",
"LANG": "\"en-us\"",
"PS1": "(awx) ",
"SUPERVISOR_ENABLED": "1",
"SHLVL": "2",
"HOME": "/var/lib/awx",
"LANGUAGE": "en_US:en",
"AWX_GROUP_QUEUES": "tower",
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
"RABBITMQ_HOST": "rabbitmq",
"CURRENT_UID": "501",
"_": "/venv/awx/bin/python3",
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
"TZ": "UTC",
"ANSIBLE_FORCE_COLOR": "True",
"ANSIBLE_HOST_KEY_CHECKING": "False",
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
"ANSIBLE_VENV_PATH": "/venv/ansible",
"PROOT_TMP_DIR": "/tmp",
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
"PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
"JOB_ID": "2",
"INVENTORY_ID": "1",
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
"ANSIBLE_RETRY_FILES_ENABLED": "False",
"MAX_EVENT_RES": "700000",
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback",
"AWX_HOST": "https://towerhost",
"ANSIBLE_SSH_CONTROL_PATH_DIR": "/tmp/awx_2_a4b1afiw/cp",
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
"AWX_ISOLATED_DATA_DIR": "/tmp/awx_2_a4b1afiw/artifacts/2"
},
"job_explanation": "",
"execution_node": "awx",
"controller_node": "",
"result_traceback": "",
"event_processing_finished": true,
"job_template": 7,
"passwords_needed_to_start": [],
"allow_simultaneous": false,
"artifacts": {},
"scm_revision": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
"instance_group": 1,
"diff_mode": false,
"job_slice_number": 0,
"job_slice_count": 1,
"host_status_counts": {
"ok": 1
},
"playbook_counts": {
"play_count": 1,
"task_count": 1
},
"custom_virtualenv": "/venv/ansible"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
import styled from 'styled-components';
export default styled.div`
display: flex;
&:hover {
background-color: white;
}
&:hover div {
background-color: white;
}
&--hidden {
display: none;
}
${({ isFirst }) => (isFirst ? 'padding-top: 10px;' : '')}
`;

View File

@ -0,0 +1,12 @@
import styled from 'styled-components';
export default styled.div`
color: #161b1f;
background-color: #ebebeb;
flex: 0 0 45px;
text-align: right;
vertical-align: top;
padding-right: 5px;
border-right: 1px solid #d7d7d7;
user-select: none;
`;

View File

@ -0,0 +1,29 @@
import styled from 'styled-components';
export default 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;
}
.content {
background: var(--pf-global--disabled-color--200);
background: linear-gradient(
to right,
#f5f5f5 10%,
#e8e8e8 18%,
#f5f5f5 33%
);
border-radius: 5px;
}
`;

View File

@ -0,0 +1,17 @@
import styled from 'styled-components';
export default 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;
`;

View File

@ -0,0 +1,4 @@
export { default as JobEventLine } from './JobEventLine';
export { default as JobEventLineToggle } from './JobEventLineToggle';
export { default as JobEventLineNumber } from './JobEventLineNumber';
export { default as JobEventLineText } from './JobEventLineText';