diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less
index 368e2a4e65..bb48a76185 100644
--- a/awx/ui/client/features/output/_index.less
+++ b/awx/ui/client/features/output/_index.less
@@ -102,6 +102,10 @@
padding-right: 5px;
border-right: 1px solid @at-gray-b7;
user-select: none;
+
+ &-clickable {
+ cursor: pointer;
+ }
}
&-event {
diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js
index b9b925f2cc..68831f2a1e 100644
--- a/awx/ui/client/features/output/render.service.js
+++ b/awx/ui/client/features/output/render.service.js
@@ -1,11 +1,13 @@
import Ansi from 'ansi-to-html';
import Entities from 'html-entities';
+import getUUID from 'uuid';
import {
EVENT_START_PLAY,
EVENT_STATS_PLAY,
EVENT_START_TASK,
OUTPUT_ELEMENT_TBODY,
+ OUTPUT_EVENT_LIMIT,
} from './constants';
const EVENT_GROUPS = [
@@ -31,46 +33,71 @@ const pattern = [
const re = new RegExp(pattern);
const hasAnsi = input => re.test(input);
-const MISSING_EVENT_GROUP = 'MISSING_EVENT_GROUP';
-
function JobRenderService ($q, $sce, $window) {
this.init = ({ compile, toggles }) => {
- this.parent = null;
- this.record = {};
- this.el = $(OUTPUT_ELEMENT_TBODY);
this.hooks = { compile };
- this.createToggles = toggles;
- this.lastMissing = false;
+ this.el = $(OUTPUT_ELEMENT_TBODY);
+ this.parent = null;
+
this.state = {
- collapseAll: false
+ head: null,
+ tail: null,
+ collapseAll: false,
+ toggleMode: toggles,
};
+
+ this.counters = {};
+ this.lines = {};
+ this.records = {};
+ this.uuids = {};
+
+ this.missingCounterRecords = {};
+ this.missingCounterUUIDs = {};
};
this.setCollapseAll = value => {
this.state.collapseAll = value;
};
- this.sortByLineNumber = (a, b) => {
- if (a.start_line > b.start_line) {
+ this.sortByCounter = (a, b) => {
+ if (a.counter > b.counter) {
return 1;
}
- if (a.start_line < b.start_line) {
+ if (a.counter < b.counter) {
return -1;
}
return 0;
};
- this.transformEventGroup = events => {
+ //
+ // Event Data Transformation / HTML Building
+ //
+
+ this.transformEventGroup = (events, streaming = false) => {
let lines = 0;
let html = '';
- events.sort(this.sortByLineNumber);
+ events.sort(this.sortByCounter);
+
+ for (let i = 0; i <= events.length - 1; i++) {
+ const current = events[i];
+
+ if (streaming) {
+ const tailCounter = this.getTailCounter();
+
+ if (tailCounter && (current.counter !== tailCounter + 1)) {
+ const missing = this.transformMissingEventGroup(current);
+
+ html += missing.html;
+ lines += missing.count;
+ }
+ }
+
+ const line = this.transformEvent(current);
- for (let i = 0; i < events.length; ++i) {
- const line = this.transformEvent(events[i]);
html += line.html;
lines += line.count;
}
@@ -78,21 +105,42 @@ function JobRenderService ($q, $sce, $window) {
return { html, lines };
};
- this.transformEvent = event => {
- if (event.uuid && this.record[event.uuid]) {
+ this.transformMissingEventGroup = event => {
+ const tail = this.lookupRecord(this.getTailCounter());
+
+ if (!tail || !tail.counter) {
return { html: '', count: 0 };
}
- if (event.event === MISSING_EVENT_GROUP) {
- if (this.lastMissing) {
- return { html: '', count: 0 };
- }
+ const uuid = getUUID();
+ const counters = [];
- this.lastMissing = true;
- return this.transformMissingEvent(event);
+ for (let i = tail.counter + 1; i < event.counter; i++) {
+ counters.push(i);
+ this.missingCounterUUIDs[i] = uuid;
}
- this.lastMissing = false;
+ const record = {
+ counters,
+ uuid,
+ start: tail.end,
+ end: event.start_line,
+ };
+
+ this.missingCounterRecords[uuid] = record;
+
+ const html = `
`;
+ const count = 1;
+
+ return { html, count };
+ };
+
+ this.transformEvent = event => {
+ if (event.uuid && this.records[event.uuid]) {
+ return { html: '', count: 0 };
+ }
if (!event || !event.stdout) {
return { html: '', count: 0 };
@@ -100,59 +148,65 @@ function JobRenderService ($q, $sce, $window) {
const stdout = this.sanitize(event.stdout);
const lines = stdout.split('\r\n');
+ const record = this.createRecord(event, lines);
+ let html = '';
let count = lines.length;
let ln = event.start_line;
- const current = this.createRecord(ln, lines, event);
-
- const html = lines.reduce((concat, line, i) => {
+ for (let i = 0; i <= lines.length - 1; i++) {
ln++;
+ const line = lines[i];
const isLastLine = i === lines.length - 1;
- let row = this.createRow(current, ln, line);
+ let row = this.createRow(record, ln, line);
- if (current && current.isTruncated && isLastLine) {
- row += this.createRow(current);
+ if (record && record.isTruncated && isLastLine) {
+ row += this.createRow(record);
count++;
}
- return `${concat}${row}`;
- }, '');
+ html += row;
+ }
return { html, count };
};
- this.transformMissingEvent = () => {
- const html = '';
- const count = 1;
-
- return { html, count };
- };
-
- this.isHostEvent = (event) => {
- if (typeof event.host === 'number') {
- return true;
+ this.createRecord = (event, lines) => {
+ if (!event.counter) {
+ return null;
}
- if (event.type === 'project_update_event' &&
- event.event !== 'runner_on_skipped' &&
- event.event_data.host) {
- return true;
+ this.lines[event.counter] = event.end_line - event.start_line;
+
+ if (this.state.tail === null ||
+ this.state.tail < event.counter) {
+ this.state.tail = event.counter;
}
- return false;
- };
+ if (this.state.head === null ||
+ this.state.head > event.counter) {
+ this.state.head = event.counter;
+ }
- this.createRecord = (ln, lines, event) => {
if (!event.uuid) {
return null;
}
- const info = {
+ let isHost = false;
+ if (typeof event.host === 'number') {
+ isHost = true;
+ } else if (event.type === 'project_update_event' &&
+ event.event !== 'runner_on_skipped' &&
+ event.event_data.host) {
+ isHost = true;
+ }
+
+ const record = {
+ isHost,
id: event.id,
- line: ln + 1,
+ line: event.start_line + 1,
name: event.event,
uuid: event.uuid,
level: event.event_level,
@@ -160,54 +214,50 @@ function JobRenderService ($q, $sce, $window) {
end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length,
lineCount: lines.length,
- isHost: this.isHostEvent(event),
isCollapsed: this.state.collapseAll,
+ counter: event.counter,
};
if (event.parent_uuid) {
- info.parents = this.getParentEvents(event.parent_uuid);
- if (this.record[event.parent_uuid]) {
- info.isCollapsed = this.record[event.parent_uuid].isCollapsed;
+ record.parents = this.getParentEvents(event.parent_uuid);
+ if (this.records[event.parent_uuid]) {
+ record.isCollapsed = this.records[event.parent_uuid].isCollapsed;
}
}
- if (info.isTruncated) {
- info.truncatedAt = event.start_line + lines.length;
+ if (record.isTruncated) {
+ record.truncatedAt = event.start_line + lines.length;
}
if (EVENT_GROUPS.includes(event.event)) {
- info.isParent = true;
+ record.isParent = true;
if (event.event_level === 1) {
this.parent = event.uuid;
}
if (event.parent_uuid) {
- if (this.record[event.parent_uuid]) {
- if (this.record[event.parent_uuid].children &&
- !this.record[event.parent_uuid].children.includes(event.uuid)) {
- this.record[event.parent_uuid].children.push(event.uuid);
+ if (this.records[event.parent_uuid]) {
+ if (this.records[event.parent_uuid].children &&
+ !this.records[event.parent_uuid].children.includes(event.uuid)) {
+ this.records[event.parent_uuid].children.push(event.uuid);
} else {
- this.record[event.parent_uuid].children = [event.uuid];
+ this.records[event.parent_uuid].children = [event.uuid];
}
}
}
}
if (TIME_EVENTS.includes(event.event)) {
- info.time = this.getTimestamp(event.created);
- info.line++;
+ record.time = this.getTimestamp(event.created);
+ record.line++;
}
- this.record[event.uuid] = info;
+ this.uuids[event.counter] = record.uuid;
+ this.counters[event.uuid] = record.counter;
+ this.records[event.uuid] = record;
- return info;
- };
-
- this.getRecord = uuid => this.record[uuid];
-
- this.deleteRecord = uuid => {
- delete this.record[uuid];
+ return record;
};
this.getParentEvents = (uuid, list) => {
@@ -215,14 +265,32 @@ function JobRenderService ($q, $sce, $window) {
// always push its parent if exists
list.push(uuid);
// if we can get grandparent in current visible lines, we also push it
- if (this.record[uuid] && this.record[uuid].parents) {
- list = list.concat(this.record[uuid].parents);
+ if (this.records[uuid] && this.records[uuid].parents) {
+ list = list.concat(this.records[uuid].parents);
}
return list;
};
- this.createRow = (current, ln, content) => {
+ this.deleteRecord = counter => {
+ const uuid = this.uuids[counter];
+
+ delete this.records[uuid];
+ delete this.counters[uuid];
+ delete this.uuids[counter];
+ delete this.lines[counter];
+ };
+
+ this.getTimestamp = created => {
+ const date = new Date(created);
+ const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours();
+ const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes();
+ const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds();
+
+ return `${hour}:${minute}:${second}`;
+ };
+
+ this.createRow = (record, ln, content) => {
let id = '';
let icon = '';
let timestamp = '';
@@ -236,11 +304,11 @@ function JobRenderService ($q, $sce, $window) {
content = ansi.toHtml(content);
}
- if (current) {
- if (this.createToggles && current.isParent && current.line === ln) {
- id = current.uuid;
+ if (record) {
+ if (this.state.toggleMode && record.isParent && record.line === ln) {
+ id = record.uuid;
- if (current.isCollapsed) {
+ if (record.isCollapsed) {
icon = 'fa-angle-right';
} else {
icon = 'fa-angle-down';
@@ -249,16 +317,16 @@ function JobRenderService ($q, $sce, $window) {
tdToggle = `
`;
}
- if (current.isHost) {
- tdEvent = `${content}
`;
+ if (record.isHost) {
+ tdEvent = `${content}
`;
}
- if (current.time && current.line === ln) {
- timestamp = `${current.time}`;
+ if (record.time && record.line === ln) {
+ timestamp = `${record.time}`;
}
- if (current.parents) {
- classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
+ if (record.parents) {
+ classList = record.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
}
}
@@ -274,8 +342,8 @@ function JobRenderService ($q, $sce, $window) {
ln = '...';
}
- if (current && current.isCollapsed) {
- if (current.level === 3 || current.level === 0) {
+ if (record && record.isCollapsed) {
+ if (record.level === 3 || record.level === 0) {
classList += ' hidden';
}
}
@@ -289,14 +357,9 @@ function JobRenderService ($q, $sce, $window) {
`;
};
- this.getTimestamp = created => {
- const date = new Date(created);
- const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours();
- const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes();
- const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds();
-
- return `${hour}:${minute}:${second}`;
- };
+ //
+ // Element Operations
+ //
this.remove = elements => this.requestAnimationFrame(() => elements.remove());
@@ -316,7 +379,7 @@ function JobRenderService ($q, $sce, $window) {
return this.requestAnimationFrame();
};
- this.clear = () => {
+ this.removeAll = () => {
const elements = this.el.children();
return this.remove(elements);
};
@@ -348,12 +411,12 @@ function JobRenderService ($q, $sce, $window) {
.then(() => result.lines);
};
- this.append = events => {
+ this.append = (events, streaming = false) => {
if (events.length < 1) {
return $q.resolve();
}
- const result = this.transformEventGroup(events);
+ const result = this.transformEventGroup(events, streaming);
const html = this.trustHtml(result.html);
const newElements = angular.element(html);
@@ -364,8 +427,189 @@ function JobRenderService ($q, $sce, $window) {
};
this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html));
-
this.sanitize = html => entities.encode(html);
+
+ //
+ // Event Counter Methods - External code should use these.
+ //
+
+ this.getTailCounter = () => {
+ if (this.state.tail === null) {
+ return 0;
+ }
+
+ if (this.state.tail < 0) {
+ return 0;
+ }
+
+ return this.state.tail;
+ };
+
+ this.getHeadCounter = () => {
+ if (this.state.head === null) {
+ return 0;
+ }
+
+ if (this.state.head < 0) {
+ return 0;
+ }
+
+ return this.state.head;
+ };
+
+ this.getCapacity = () => OUTPUT_EVENT_LIMIT - Object.keys(this.lines).length;
+
+ this.lookupRecord = counter => this.records[this.uuids[counter]];
+
+ this.getLineCount = counter => {
+ const record = this.lookupRecord(counter);
+
+ if (record && record.lineCount) {
+ return record.lineCount;
+ }
+
+ if (this.lines[counter]) {
+ return this.lines[counter];
+ }
+
+ return 0;
+ };
+
+ this.deleteMissingCounterRecord = counter => {
+ const uuid = this.missingCounterUUIDs[counter];
+
+ delete this.missingCounterRecords[counter];
+ delete this.missingCounterUUIDs[uuid];
+ };
+
+ this.clear = () => this.removeAll()
+ .then(() => {
+ const head = this.getHeadCounter();
+ const tail = this.getTailCounter();
+
+ for (let i = head; i <= tail; ++i) {
+ this.deleteRecord(i);
+ this.deleteMissingCounterRecord(i);
+ }
+
+ this.state.head = null;
+ this.state.tail = null;
+
+ return $q.resolve();
+ });
+
+ this.pushFront = (events, streaming = false) => {
+ const tail = this.getTailCounter();
+
+ return this.append(events.filter(({ counter }) => counter > tail), streaming);
+ };
+
+ this.pushBack = events => {
+ const head = this.getHeadCounter();
+ const tail = this.getTailCounter();
+
+ return this.prepend(events.filter(({ counter }) => counter < head || counter > tail));
+ };
+
+ this.pushFrames = events => this.pushFront(events, true);
+
+ this.popMissing = counter => {
+ const uuid = this.missingCounterUUIDs[counter];
+
+ if (!this.missingCounterRecords[uuid]) {
+ return 0;
+ }
+
+ this.missingCounterRecords[uuid].counters.pop();
+
+ if (this.missingCounterRecords[uuid].counters.length > 0) {
+ return 0;
+ }
+
+ delete this.missingCounterRecords[uuid];
+ delete this.missingCounterUUIDs[counter];
+
+ return 1;
+ };
+
+ this.shiftMissing = counter => {
+ const uuid = this.missingCounterUUIDs[counter];
+
+ if (!this.missingCounterRecords[uuid]) {
+ return 0;
+ }
+
+ this.missingCounterRecords[uuid].counters.shift();
+
+ if (this.missingCounterRecords[uuid].counters.length > 0) {
+ return 0;
+ }
+
+ delete this.missingCounterRecords[uuid];
+ delete this.missingCounterUUIDs[counter];
+
+ return 1;
+ };
+
+ this.isCounterMissing = counter => this.missingCounterUUIDs[counter];
+
+ this.popFront = count => {
+ if (!count || count <= 0) {
+ return $q.resolve();
+ }
+
+ const max = this.getTailCounter();
+ const min = max - count;
+
+ let lines = 0;
+
+ for (let i = max; i >= min; --i) {
+ if (this.isCounterMissing(i)) {
+ lines += this.popMissing(i);
+ } else {
+ lines += this.getLineCount(i);
+ }
+ }
+
+ return this.pop(lines)
+ .then(() => {
+ for (let i = max; i >= min; --i) {
+ this.deleteRecord(i);
+ this.state.tail--;
+ }
+
+ return $q.resolve();
+ });
+ };
+
+ this.popBack = count => {
+ if (!count || count <= 0) {
+ return $q.resolve();
+ }
+
+ const min = this.getHeadCounter();
+ const max = min + count;
+
+ let lines = 0;
+
+ for (let i = min; i <= max; ++i) {
+ if (this.isCounterMissing(i)) {
+ lines += this.popMissing(i);
+ } else {
+ lines += this.getLineCount(i);
+ }
+ }
+
+ return this.shift(lines)
+ .then(() => {
+ for (let i = min; i <= max; ++i) {
+ this.deleteRecord(i);
+ this.state.head++;
+ }
+
+ return $q.resolve();
+ });
+ };
}
JobRenderService.$inject = ['$q', '$sce', '$window'];