mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03:30
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:
commit
31308e3795
115
awx/ui_next/package-lock.json
generated
115
awx/ui_next/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
109
awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
Normal file
109
awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
Normal 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;
|
||||
66
awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
Normal file
66
awx/ui_next/src/screens/Job/JobOutput/JobEvent.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
31
awx/ui_next/src/screens/Job/JobOutput/JobEventSkeleton.jsx
Normal file
31
awx/ui_next/src/screens/Job/JobOutput/JobEventSkeleton.jsx
Normal 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;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
54
awx/ui_next/src/screens/Job/JobOutput/MenuControls.jsx
Normal file
54
awx/ui_next/src/screens/Job/JobOutput/MenuControls.jsx
Normal 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;
|
||||
35
awx/ui_next/src/screens/Job/JobOutput/MenuControls.test.jsx
Normal file
35
awx/ui_next/src/screens/Job/JobOutput/MenuControls.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
194
awx/ui_next/src/screens/Job/JobOutput/data.job.json
Normal file
194
awx/ui_next/src/screens/Job/JobOutput/data.job.json
Normal 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"
|
||||
}
|
||||
8461
awx/ui_next/src/screens/Job/JobOutput/data.job_events.json
Normal file
8461
awx/ui_next/src/screens/Job/JobOutput/data.job_events.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;' : '')}
|
||||
`;
|
||||
@ -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;
|
||||
`;
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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;
|
||||
`;
|
||||
4
awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx
Normal file
4
awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx
Normal 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';
|
||||
Loading…
x
Reference in New Issue
Block a user