diff --git a/awx/ui/src/screens/Job/JobOutput/JobOutput.js b/awx/ui/src/screens/Job/JobOutput/JobOutput.js
index 47f4fddd3f..15bf7e83af 100644
--- a/awx/ui/src/screens/Job/JobOutput/JobOutput.js
+++ b/awx/ui/src/screens/Job/JobOutput/JobOutput.js
@@ -9,148 +9,36 @@ import {
InfiniteLoader,
List,
} from 'react-virtualized';
-import Ansi from 'ansi-to-html';
-import hasAnsi from 'has-ansi';
-import { encode } from 'html-entities';
-import {
- Button,
- Toolbar,
- ToolbarContent,
- ToolbarItem,
- ToolbarToggleGroup,
- Tooltip,
-} from '@patternfly/react-core';
-import { SearchIcon } from '@patternfly/react-icons';
+import { Button } from '@patternfly/react-core';
import AlertModal from 'components/AlertModal';
import { CardBody as _CardBody } from 'components/Card';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import ErrorDetail from 'components/ErrorDetail';
-import Search from 'components/Search';
import StatusIcon from 'components/StatusIcon';
import { getJobModel, isJobRunning } from 'util/jobs';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import useInterval from 'hooks/useInterval';
-import {
- parseQueryString,
- mergeParams,
- removeParams,
- getQSConfig,
- updateQueryString,
-} from 'util/qs';
+import { parseQueryString, getQSConfig } from 'util/qs';
import useIsMounted from 'hooks/useIsMounted';
import JobEvent from './JobEvent';
import JobEventSkeleton from './JobEventSkeleton';
import PageControls from './PageControls';
import HostEventModal from './HostEventModal';
+import JobOutputSearch from './JobOutputSearch';
import { HostStatusBar, OutputToolbar } from './shared';
-import getRowRangePageSize from './shared/jobOutputUtils';
+import getLineTextHtml from './getLineTextHtml';
+import connectJobSocket, { closeWebSocket } from './connectJobSocket';
+import getEventRequestParams from './getEventRequestParams';
+import isHostEvent from './isHostEvent';
+import { fetchCount, normalizeEvents } from './loadJobEvents';
const QS_CONFIG = getQSConfig('job_output', {
order_by: 'counter',
});
-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: '#A30000',
- 2: '#486B00',
- 3: '#795600',
- 4: '#00A',
- 5: '#A0A',
- 6: '#004368',
- 7: '#AAA',
- 8: '#555',
- 9: '#F55',
- 10: '#5F5',
- 11: '#FF5',
- 12: '#55F',
- 13: '#F5F',
- 14: '#5FF',
- 15: '#FFF',
- },
-});
-
-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}`;
-}
-
-const styleAttrPattern = new RegExp('style="[^"]*"', 'g');
-
-function createStyleAttrHash(styleAttr) {
- let hash = 0;
- for (let i = 0; i < styleAttr.length; i++) {
- hash = (hash << 5) - hash; // eslint-disable-line no-bitwise
- hash += styleAttr.charCodeAt(i);
- hash &= hash; // eslint-disable-line no-bitwise
- }
- return `${hash}`;
-}
-
-function replaceStyleAttrs(html) {
- const allStyleAttrs = [...new Set(html.match(styleAttrPattern))];
- const cssMap = {};
- let result = html;
- for (let i = 0; i < allStyleAttrs.length; i++) {
- const styleAttr = allStyleAttrs[i];
- const cssClassName = `output-${createStyleAttrHash(styleAttr)}`;
-
- cssMap[cssClassName] = styleAttr.replace('style="', '').slice(0, -1);
- result = result.split(styleAttr).join(`class="${cssClassName}"`);
- }
- return { cssMap, result };
-}
-
-function getLineTextHtml({ created, event, start_line, stdout }) {
- const sanitized = encode(stdout);
- let lineCssMap = {};
- const lineTextHtml = [];
-
- sanitized.split('\r\n').forEach((lineText, index) => {
- let html;
- if (hasAnsi(lineText)) {
- const { cssMap, result } = replaceStyleAttrs(ansi.toHtml(lineText));
- html = result;
- lineCssMap = { ...lineCssMap, ...cssMap };
- } else {
- html = lineText;
- }
-
- if (index === 1 && TIME_EVENTS.includes(event)) {
- const time = getTimestamp({ created });
- html += `${time}`;
- }
-
- lineTextHtml.push({
- lineNumber: start_line + index,
- html,
- });
- });
-
- return {
- lineCssMap,
- lineTextHtml,
- };
-}
-
const CardBody = styled(_CardBody)`
display: flex;
flex-flow: column;
@@ -192,110 +80,11 @@ const OutputFooter = styled.div`
flex: 1;
`;
-const SearchToolbar = styled(Toolbar)`
- position: inherit !important;
-`;
-
-const SearchToolbarContent = styled(ToolbarContent)`
- padding-left: 0px !important;
- padding-right: 0px !important;
-`;
-
-let ws;
-function connectJobSocket({ type, id }, onMessage) {
- ws = new WebSocket(
- `${window.location.protocol === 'http:' ? 'ws:' : 'wss:'}//${
- window.location.host
- }/websocket/`
- );
-
- ws.onopen = () => {
- const xrftoken = `; ${document.cookie}`
- .split('; csrftoken=')
- .pop()
- .split(';')
- .shift();
- const eventGroup = `${type}_events`;
- ws.send(
- JSON.stringify({
- xrftoken,
- groups: { jobs: ['summary', 'status_changed'], [eventGroup]: [id] },
- })
- );
- };
-
- ws.onmessage = (e) => {
- onMessage(JSON.parse(e.data));
- };
-
- ws.onclose = (e) => {
- if (e.code !== 1000) {
- // eslint-disable-next-line no-console
- console.debug('Socket closed. Reconnecting...', e);
- setTimeout(() => {
- connectJobSocket({ type, id }, onMessage);
- }, 1000);
- }
- };
-
- ws.onerror = (err) => {
- // eslint-disable-next-line no-console
- console.debug('Socket error: ', err, 'Disconnecting...');
- ws.close();
- };
-}
-
-function range(low, high) {
- const numbers = [];
- for (let n = low; n <= high; n++) {
- numbers.push(n);
- }
- return numbers;
-}
-
-function isHostEvent(jobEvent) {
- const { event, event_data, host, type } = jobEvent;
- let isHost;
- if (typeof host === 'number' || (event_data && event_data.res)) {
- isHost = true;
- } else if (
- type === 'project_update_event' &&
- event !== 'runner_on_skipped' &&
- event_data.host
- ) {
- isHost = true;
- } else {
- isHost = false;
- }
- return isHost;
-}
-
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 25,
});
-const getEventRequestParams = (job, remoteRowCount, requestRange) => {
- const [startIndex, stopIndex] = requestRange;
- if (isJobRunning(job?.status)) {
- return [
- { counter__gte: startIndex, limit: stopIndex - startIndex + 1 },
- range(startIndex, Math.min(stopIndex, remoteRowCount)),
- startIndex,
- ];
- }
- const { page, pageSize, firstIndex } = getRowRangePageSize(
- startIndex,
- stopIndex
- );
- const loadRange = range(
- firstIndex,
- Math.min(firstIndex + pageSize, remoteRowCount)
- );
-
- return [{ page, page_size: pageSize }, loadRange, firstIndex];
-};
-
function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
const location = useLocation();
const listRef = useRef(null);
@@ -350,9 +139,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}
return function cleanup() {
- if (ws) {
- ws.close();
- }
+ closeWebSocket();
setIsMonitoringWebsocket(false);
isMounted.current = false;
};
@@ -436,73 +223,29 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
...parseQueryString(QS_CONFIG, location.search),
});
- let countRequest;
- if (isJobRunning(job?.status)) {
- // If the job is running, it means we're using limit-offset pagination. Requests
- // with limit-offset pagination won't return a total event count for performance
- // reasons. In this situation, we derive the remote row count by using the highest
- // counter available in the database.
- countRequest = async () => {
- const {
- data: { results: lastEvents = [] },
- } = await getJobModel(job.type).readEvents(job.id, {
- order_by: '-counter',
- limit: 1,
- });
- return lastEvents.length >= 1 ? lastEvents[0].counter : 0;
- };
- } else {
- countRequest = async () => {
- const {
- data: { count: eventCount },
- } = await eventPromise;
- return eventCount;
- };
- }
-
try {
const [
{
data: { results: fetchedEvents = [] },
},
count,
- ] = await Promise.all([eventPromise, countRequest()]);
+ ] = await Promise.all([eventPromise, fetchCount(job, eventPromise)]);
- if (isMounted.current) {
- let countOffset = 0;
- if (job?.result_traceback) {
- const tracebackEvent = {
- counter: 1,
- created: null,
- event: null,
- type: null,
- stdout: job?.result_traceback,
- start_line: 0,
- };
- const firstIndex = fetchedEvents.findIndex(
- (jobEvent) => jobEvent.counter === 1
- );
- if (firstIndex && fetchedEvents[firstIndex]?.stdout) {
- const stdoutLines = fetchedEvents[firstIndex].stdout.split('\r\n');
- stdoutLines[0] = tracebackEvent.stdout;
- fetchedEvents[firstIndex].stdout = stdoutLines.join('\r\n');
- } else {
- countOffset += 1;
- fetchedEvents.unshift(tracebackEvent);
- }
- }
-
- const newResults = {};
- let newResultsCssMap = {};
- fetchedEvents.forEach((jobEvent, index) => {
- newResults[index] = jobEvent;
- const { lineCssMap } = getLineTextHtml(jobEvent);
- newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
- });
- setResults(newResults);
- setRemoteRowCount(count + countOffset);
- setCssMap(newResultsCssMap);
+ if (!isMounted.current) {
+ return;
}
+ const { events, countOffset } = normalizeEvents(job, fetchedEvents);
+
+ const newResults = {};
+ let newResultsCssMap = {};
+ events.forEach((jobEvent, index) => {
+ newResults[index] = jobEvent;
+ const { lineCssMap } = getLineTextHtml(jobEvent);
+ newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
+ });
+ setResults(newResults);
+ setRemoteRowCount(count + countOffset);
+ setCssMap(newResultsCssMap);
} catch (err) {
setContentError(err);
} finally {
@@ -603,29 +346,31 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
return getJobModel(job.type)
.readEvents(job.id, params)
.then((response) => {
- if (isMounted.current) {
- const newResults = {};
- let newResultsCssMap = {};
- response.data.results.forEach((jobEvent, index) => {
- newResults[firstIndex + index] = jobEvent;
- const { lineCssMap } = getLineTextHtml(jobEvent);
- newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
- });
- setResults((prevResults) => ({
- ...prevResults,
- ...newResults,
- }));
- setCssMap((prevCssMap) => ({
- ...prevCssMap,
- ...newResultsCssMap,
- }));
- setCurrentlyLoading((prevCurrentlyLoading) =>
- prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
- );
- loadRange.forEach((n) => {
- cache.clear(n);
- });
+ if (!isMounted.current) {
+ return;
}
+
+ const newResults = {};
+ let newResultsCssMap = {};
+ response.data.results.forEach((jobEvent, index) => {
+ newResults[firstIndex + index] = jobEvent;
+ const { lineCssMap } = getLineTextHtml(jobEvent);
+ newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
+ });
+ setResults((prevResults) => ({
+ ...prevResults,
+ ...newResults,
+ }));
+ setCssMap((prevCssMap) => ({
+ ...prevCssMap,
+ ...newResultsCssMap,
+ }));
+ setCurrentlyLoading((prevCurrentlyLoading) =>
+ prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
+ );
+ loadRange.forEach((n) => {
+ cache.clear(n);
+ });
});
};
@@ -665,55 +410,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
previousWidth.current = width;
};
- const handleSearch = (key, value) => {
- const params = parseQueryString(QS_CONFIG, location.search);
- const qs = updateQueryString(
- QS_CONFIG,
- location.search,
- mergeParams(params, { [key]: value })
- );
- pushHistoryState(qs);
- };
-
- const handleReplaceSearch = (key, value) => {
- const qs = updateQueryString(QS_CONFIG, location.search, {
- [key]: value,
- });
- pushHistoryState(qs);
- };
-
- const handleRemoveSearchTerm = (key, value) => {
- const oldParams = parseQueryString(QS_CONFIG, location.search);
- const updatedParams = removeParams(QS_CONFIG, oldParams, {
- [key]: value,
- });
- const qs = updateQueryString(QS_CONFIG, location.search, updatedParams);
- pushHistoryState(qs);
- };
-
- const handleRemoveAllSearchTerms = () => {
- const oldParams = parseQueryString(QS_CONFIG, location.search);
- Object.keys(oldParams).forEach((key) => {
- oldParams[key] = null;
- });
- const qs = updateQueryString(QS_CONFIG, location.search, oldParams);
- pushHistoryState(qs);
- };
-
- const pushHistoryState = (qs) => {
- const { pathname } = history.location;
- history.push(qs ? `${pathname}?${qs}` : pathname);
- };
-
- const handleFollowToggle = () => {
- if (isFollowModeEnabled) {
- setIsFollowModeEnabled(false);
- } else {
- setIsFollowModeEnabled(true);
- scrollToRow(remoteRowCount - 1);
- }
- };
-
const handleScroll = (e) => {
if (
isFollowModeEnabled &&
@@ -726,64 +422,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
scrollHeight.current = e.scrollHeight;
};
- const renderSearchComponent = () => (
- {}}
- onRemove={handleRemoveSearchTerm}
- isDisabled={isJobRunning(job.status)}
- />
- );
-
if (contentError) {
return ;
}
@@ -812,36 +450,16 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
/>
-
-
- } breakpoint="lg">
-
- {isJobRunning(job.status) ? (
-
- {renderSearchComponent()}
-
- ) : (
- renderSearchComponent()
- )}
-
-
- {isJobRunning(job.status) ? (
-
- ) : null}
-
-
+
{
+ const params = parseQueryString(qsConfig, location.search);
+ const qs = updateQueryString(
+ qsConfig,
+ location.search,
+ mergeParams(params, { [key]: value })
+ );
+ pushHistoryState(qs);
+ };
+
+ const handleReplaceSearch = (key, value) => {
+ const qs = updateQueryString(qsConfig, location.search, {
+ [key]: value,
+ });
+ pushHistoryState(qs);
+ };
+
+ const handleRemoveSearchTerm = (key, value) => {
+ const oldParams = parseQueryString(qsConfig, location.search);
+ const updatedParams = removeParams(qsConfig, oldParams, {
+ [key]: value,
+ });
+ const qs = updateQueryString(qsConfig, location.search, updatedParams);
+ pushHistoryState(qs);
+ };
+
+ const handleRemoveAllSearchTerms = () => {
+ const oldParams = parseQueryString(qsConfig, location.search);
+ Object.keys(oldParams).forEach((key) => {
+ oldParams[key] = null;
+ });
+ const qs = updateQueryString(qsConfig, location.search, oldParams);
+ pushHistoryState(qs);
+ };
+
+ const pushHistoryState = (qs) => {
+ const { pathname } = history.location;
+ history.push(qs ? `${pathname}?${qs}` : pathname);
+ };
+
+ const handleFollowToggle = () => {
+ if (isFollowModeEnabled) {
+ setIsFollowModeEnabled(false);
+ } else {
+ setIsFollowModeEnabled(true);
+ scrollToRow(remoteRowCount - 1);
+ }
+ };
+
+ const columns = [
+ {
+ name: t`Stdout`,
+ key: 'stdout__icontains',
+ isDefault: true,
+ },
+ {
+ name: t`Event`,
+ key: 'event',
+ options: [
+ ['runner_on_failed', t`Host Failed`],
+ ['runner_on_start', t`Host Started`],
+ ['runner_on_ok', t`Host OK`],
+ ['runner_on_error', t`Host Failure`],
+ ['runner_on_skipped', t`Host Skipped`],
+ ['runner_on_unreachable', t`Host Unreachable`],
+ ['runner_on_no_hosts', t`No Hosts Remaining`],
+ ['runner_on_async_poll', t`Host Polling`],
+ ['runner_on_async_ok', t`Host Async OK`],
+ ['runner_on_async_failed', t`Host Async Failure`],
+ ['runner_item_on_ok', t`Item OK`],
+ ['runner_item_on_failed', t`Item Failed`],
+ ['runner_item_on_skipped', t`Item Skipped`],
+ ['runner_retry', t`Host Retry`],
+ ['runner_on_file_diff', t`File Difference`],
+ ['playbook_on_start', t`Playbook Started`],
+ ['playbook_on_notify', t`Running Handlers`],
+ ['playbook_on_include', t`Including File`],
+ ['playbook_on_no_hosts_matched', t`No Hosts Matched`],
+ ['playbook_on_no_hosts_remaining', t`No Hosts Remaining`],
+ ['playbook_on_task_start', t`Task Started`],
+ ['playbook_on_vars_prompt', t`Variables Prompted`],
+ ['playbook_on_setup', t`Gathering Facts`],
+ ['playbook_on_play_start', t`Play Started`],
+ ['playbook_on_stats', t`Playbook Complete`],
+ ['debug', t`Debug`],
+ ['verbose', t`Verbose`],
+ ['deprecated', t`Deprecated`],
+ ['warning', t`Warning`],
+ ['system_warning', t`System Warning`],
+ ['error', t`Error`],
+ ],
+ },
+ { name: t`Advanced`, key: 'advanced' },
+ ];
+ const isDisabled = isJobRunning(job.status);
+
+ return (
+
+
+ } breakpoint="lg">
+
+ {isDisabled ? (
+
+ {}}
+ onRemove={handleRemoveSearchTerm}
+ isDisabled
+ />
+
+ ) : (
+ {}}
+ onRemove={handleRemoveSearchTerm}
+ />
+ )}
+
+
+ {isJobRunning(job.status) ? (
+
+ ) : null}
+
+
+ );
+}
+
+export default JobOutputSearch;
diff --git a/awx/ui/src/screens/Job/JobOutput/PageControls.js b/awx/ui/src/screens/Job/JobOutput/PageControls.js
index 70d6ab07a2..025859f54f 100644
--- a/awx/ui/src/screens/Job/JobOutput/PageControls.js
+++ b/awx/ui/src/screens/Job/JobOutput/PageControls.js
@@ -2,7 +2,7 @@ import 'styled-components/macro';
import React from 'react';
import { t } from '@lingui/macro';
-import { Button as PFButton } from '@patternfly/react-core';
+import { Button } from '@patternfly/react-core';
import {
AngleDoubleUpIcon,
AngleDoubleDownIcon,
@@ -14,16 +14,11 @@ import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
height: 35px;
- outline: 1px solid #d7d7d7;
+ border: 1px solid #d7d7d7;
width: 100%;
justify-content: flex-end;
`;
-const Button = styled(PFButton)`
- position: relative;
- z-index: 1;
-`;
-
const PageControls = ({
onScrollFirst,
onScrollLast,
diff --git a/awx/ui/src/screens/Job/JobOutput/connectJobSocket.js b/awx/ui/src/screens/Job/JobOutput/connectJobSocket.js
new file mode 100644
index 0000000000..58c6fb4ac6
--- /dev/null
+++ b/awx/ui/src/screens/Job/JobOutput/connectJobSocket.js
@@ -0,0 +1,50 @@
+let ws;
+
+export default function connectJobSocket({ type, id }, onMessage) {
+ ws = new WebSocket(
+ `${window.location.protocol === 'http:' ? 'ws:' : 'wss:'}//${
+ window.location.host
+ }/websocket/`
+ );
+
+ ws.onopen = () => {
+ const xrftoken = `; ${document.cookie}`
+ .split('; csrftoken=')
+ .pop()
+ .split(';')
+ .shift();
+ const eventGroup = `${type}_events`;
+ ws.send(
+ JSON.stringify({
+ xrftoken,
+ groups: { jobs: ['summary', 'status_changed'], [eventGroup]: [id] },
+ })
+ );
+ };
+
+ ws.onmessage = (e) => {
+ onMessage(JSON.parse(e.data));
+ };
+
+ ws.onclose = (e) => {
+ if (e.code !== 1000) {
+ // eslint-disable-next-line no-console
+ console.debug('Socket closed. Reconnecting...', e);
+ setTimeout(() => {
+ connectJobSocket({ type, id }, onMessage);
+ }, 1000);
+ }
+ };
+
+ ws.onerror = (err) => {
+ // eslint-disable-next-line no-console
+ console.debug('Socket error: ', err, 'Disconnecting...');
+ ws.close();
+ };
+}
+
+export function closeWebSocket() {
+ if (ws) {
+ ws.close();
+ }
+}
diff --git a/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js
new file mode 100644
index 0000000000..07ac49a768
--- /dev/null
+++ b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js
@@ -0,0 +1,35 @@
+import { isJobRunning } from 'util/jobs';
+import getRowRangePageSize from './shared/jobOutputUtils';
+
+export default function getEventRequestParams(
+ job,
+ remoteRowCount,
+ requestRange
+) {
+ const [startIndex, stopIndex] = requestRange;
+ if (isJobRunning(job?.status)) {
+ return [
+ { counter__gte: startIndex, limit: stopIndex - startIndex + 1 },
+ range(startIndex, Math.min(stopIndex, remoteRowCount)),
+ startIndex,
+ ];
+ }
+ const { page, pageSize, firstIndex } = getRowRangePageSize(
+ startIndex,
+ stopIndex
+ );
+ const loadRange = range(
+ firstIndex,
+ Math.min(firstIndex + pageSize, remoteRowCount)
+ );
+
+ return [{ page, page_size: pageSize }, loadRange, firstIndex];
+}
+
+function range(low, high) {
+ const numbers = [];
+ for (let n = low; n <= high; n++) {
+ numbers.push(n);
+ }
+ return numbers;
+}
diff --git a/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js b/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js
new file mode 100644
index 0000000000..1ac82bd0c4
--- /dev/null
+++ b/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js
@@ -0,0 +1,107 @@
+import Ansi from 'ansi-to-html';
+import hasAnsi from 'has-ansi';
+import { encode } from 'html-entities';
+
+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: '#A30000',
+ 2: '#486B00',
+ 3: '#795600',
+ 4: '#00A',
+ 5: '#A0A',
+ 6: '#004368',
+ 7: '#AAA',
+ 8: '#555',
+ 9: '#F55',
+ 10: '#5F5',
+ 11: '#FF5',
+ 12: '#55F',
+ 13: '#F5F',
+ 14: '#5FF',
+ 15: '#FFF',
+ },
+});
+
+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 createStyleAttrHash(styleAttr) {
+ let hash = 0;
+ for (let i = 0; i < styleAttr.length; i++) {
+ hash = (hash << 5) - hash; // eslint-disable-line no-bitwise
+ hash += styleAttr.charCodeAt(i);
+ hash &= hash; // eslint-disable-line no-bitwise
+ }
+ return `${hash}`;
+}
+
+const styleAttrPattern = new RegExp('style="[^"]*"', 'g');
+
+function replaceStyleAttrs(html) {
+ const allStyleAttrs = [...new Set(html.match(styleAttrPattern))];
+ const cssMap = {};
+ let result = html;
+ for (let i = 0; i < allStyleAttrs.length; i++) {
+ const styleAttr = allStyleAttrs[i];
+ const cssClassName = `output-${createStyleAttrHash(styleAttr)}`;
+
+ cssMap[cssClassName] = styleAttr.replace('style="', '').slice(0, -1);
+ result = result.split(styleAttr).join(`class="${cssClassName}"`);
+ }
+ return { cssMap, result };
+}
+
+export default function getLineTextHtml({
+ created,
+ event,
+ start_line,
+ stdout,
+}) {
+ const sanitized = encode(stdout);
+ let lineCssMap = {};
+ const lineTextHtml = [];
+
+ sanitized.split('\r\n').forEach((lineText, index) => {
+ let html;
+ if (hasAnsi(lineText)) {
+ const { cssMap, result } = replaceStyleAttrs(ansi.toHtml(lineText));
+ html = result;
+ lineCssMap = { ...lineCssMap, ...cssMap };
+ } else {
+ html = lineText;
+ }
+
+ if (index === 1 && TIME_EVENTS.includes(event)) {
+ const time = getTimestamp({ created });
+ html += `${time}`;
+ }
+
+ lineTextHtml.push({
+ lineNumber: start_line + index,
+ html,
+ });
+ });
+
+ return {
+ lineCssMap,
+ lineTextHtml,
+ };
+}
diff --git a/awx/ui/src/screens/Job/JobOutput/isHostEvent.js b/awx/ui/src/screens/Job/JobOutput/isHostEvent.js
new file mode 100644
index 0000000000..30922b626f
--- /dev/null
+++ b/awx/ui/src/screens/Job/JobOutput/isHostEvent.js
@@ -0,0 +1,16 @@
+export default function isHostEvent(jobEvent) {
+ const { event, event_data, host, type } = jobEvent;
+ let isHost;
+ if (typeof host === 'number' || (event_data && event_data.res)) {
+ isHost = true;
+ } else if (
+ type === 'project_update_event' &&
+ event !== 'runner_on_skipped' &&
+ event_data.host
+ ) {
+ isHost = true;
+ } else {
+ isHost = false;
+ }
+ return isHost;
+}
diff --git a/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js b/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js
new file mode 100644
index 0000000000..9c9033e927
--- /dev/null
+++ b/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js
@@ -0,0 +1,46 @@
+import { getJobModel, isJobRunning } from 'util/jobs';
+
+export async function fetchCount(job, eventPromise) {
+ if (isJobRunning(job?.status)) {
+ const {
+ data: { results: lastEvents = [] },
+ } = await getJobModel(job.type).readEvents(job.id, {
+ order_by: '-counter',
+ limit: 1,
+ });
+ return lastEvents.length >= 1 ? lastEvents[0].counter : 0;
+ }
+
+ const {
+ data: { count: eventCount },
+ } = await eventPromise;
+ return eventCount;
+}
+
+export function normalizeEvents(job, events) {
+ let countOffset = 0;
+ if (job?.result_traceback) {
+ const tracebackEvent = {
+ counter: 1,
+ created: null,
+ event: null,
+ type: null,
+ stdout: job?.result_traceback,
+ start_line: 0,
+ };
+ const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1);
+ if (firstIndex && events[firstIndex]?.stdout) {
+ const stdoutLines = events[firstIndex].stdout.split('\r\n');
+ stdoutLines[0] = tracebackEvent.stdout;
+ events[firstIndex].stdout = stdoutLines.join('\r\n');
+ } else {
+ countOffset += 1;
+ events.unshift(tracebackEvent);
+ }
+ }
+
+ return {
+ events,
+ countOffset,
+ };
+}