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, + }; +}