From ee348b7169d63a45c9491040c3c3bb8e2850f0ed Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sun, 19 Aug 2018 21:54:34 -0400 Subject: [PATCH] add handling for discontinuities in render service --- awx/ui/client/features/output/_index.less | 4 + .../client/features/output/render.service.js | 444 ++++++++++++++---- 2 files changed, 348 insertions(+), 100 deletions(-) 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'];