mirror of
https://github.com/ansible/awx.git
synced 2026-01-16 04:10:44 -03:30
Merge pull request #10909 from keithjgrant/6613-job-output-collapsing
Refactor Job Output component
This commit is contained in:
commit
c095f0fc19
@ -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 += `<span class="time">${time}</span>`;
|
||||
}
|
||||
|
||||
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 = () => (
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
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' },
|
||||
]}
|
||||
searchableKeys={eventSearchableKeys}
|
||||
relatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
onSearch={handleSearch}
|
||||
onReplaceSearch={handleReplaceSearch}
|
||||
onShowAdvancedSearch={() => {}}
|
||||
onRemove={handleRemoveSearchTerm}
|
||||
isDisabled={isJobRunning(job.status)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
@ -812,36 +450,16 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
/>
|
||||
</OutputHeader>
|
||||
<HostStatusBar counts={job.host_status_counts} />
|
||||
<SearchToolbar
|
||||
id="job_output-toolbar"
|
||||
clearAllFilters={handleRemoveAllSearchTerms}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
clearFiltersButtonText={t`Clear all filters`}
|
||||
>
|
||||
<SearchToolbarContent>
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem variant="search-filter">
|
||||
{isJobRunning(job.status) ? (
|
||||
<Tooltip
|
||||
content={t`Search is disabled while the job is running`}
|
||||
>
|
||||
{renderSearchComponent()}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderSearchComponent()
|
||||
)}
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{isJobRunning(job.status) ? (
|
||||
<Button
|
||||
variant={isFollowModeEnabled ? 'secondary' : 'primary'}
|
||||
onClick={handleFollowToggle}
|
||||
>
|
||||
{isFollowModeEnabled ? t`Unfollow` : t`Follow`}
|
||||
</Button>
|
||||
) : null}
|
||||
</SearchToolbarContent>
|
||||
</SearchToolbar>
|
||||
<JobOutputSearch
|
||||
qsConfig={QS_CONFIG}
|
||||
job={job}
|
||||
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
eventSearchableKeys={eventSearchableKeys}
|
||||
remoteRowCount={remoteRowCount}
|
||||
scrollToRow={scrollToRow}
|
||||
isFollowModeEnabled={isFollowModeEnabled}
|
||||
setIsFollowModeEnabled={setIsFollowModeEnabled}
|
||||
/>
|
||||
<PageControls
|
||||
onScrollFirst={handleScrollFirst}
|
||||
onScrollLast={handleScrollLast}
|
||||
@ -946,5 +564,4 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
);
|
||||
}
|
||||
|
||||
export { JobOutput as _JobOutput };
|
||||
export default JobOutput;
|
||||
|
||||
188
awx/ui/src/screens/Job/JobOutput/JobOutputSearch.js
Normal file
188
awx/ui/src/screens/Job/JobOutput/JobOutputSearch.js
Normal file
@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
ToolbarToggleGroup,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from '@patternfly/react-core';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
import Search from 'components/Search';
|
||||
import {
|
||||
parseQueryString,
|
||||
mergeParams,
|
||||
removeParams,
|
||||
updateQueryString,
|
||||
} from 'util/qs';
|
||||
import { isJobRunning } from 'util/jobs';
|
||||
|
||||
const SearchToolbarContent = styled(ToolbarContent)`
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
`;
|
||||
|
||||
function JobOutputSearch({
|
||||
qsConfig,
|
||||
job,
|
||||
eventRelatedSearchableKeys,
|
||||
eventSearchableKeys,
|
||||
remoteRowCount,
|
||||
scrollToRow,
|
||||
isFollowModeEnabled,
|
||||
setIsFollowModeEnabled,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSearch = (key, value) => {
|
||||
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 (
|
||||
<Toolbar
|
||||
id="job_output-toolbar"
|
||||
clearAllFilters={handleRemoveAllSearchTerms}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
clearFiltersButtonText={t`Clear all filters`}
|
||||
>
|
||||
<SearchToolbarContent>
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem variant="search-filter">
|
||||
{isDisabled ? (
|
||||
<Tooltip content={t`Search is disabled while the job is running`}>
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={columns}
|
||||
searchableKeys={eventSearchableKeys}
|
||||
relatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
onSearch={handleSearch}
|
||||
onReplaceSearch={handleReplaceSearch}
|
||||
onShowAdvancedSearch={() => {}}
|
||||
onRemove={handleRemoveSearchTerm}
|
||||
isDisabled
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Search
|
||||
qsConfig={qsConfig}
|
||||
columns={columns}
|
||||
searchableKeys={eventSearchableKeys}
|
||||
relatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
onSearch={handleSearch}
|
||||
onReplaceSearch={handleReplaceSearch}
|
||||
onShowAdvancedSearch={() => {}}
|
||||
onRemove={handleRemoveSearchTerm}
|
||||
/>
|
||||
)}
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
{isJobRunning(job.status) ? (
|
||||
<Button
|
||||
variant={isFollowModeEnabled ? 'secondary' : 'primary'}
|
||||
onClick={handleFollowToggle}
|
||||
>
|
||||
{isFollowModeEnabled ? t`Unfollow` : t`Follow`}
|
||||
</Button>
|
||||
) : null}
|
||||
</SearchToolbarContent>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
export default JobOutputSearch;
|
||||
@ -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,
|
||||
|
||||
50
awx/ui/src/screens/Job/JobOutput/connectJobSocket.js
Normal file
50
awx/ui/src/screens/Job/JobOutput/connectJobSocket.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
35
awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js
Normal file
35
awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js
Normal file
@ -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;
|
||||
}
|
||||
107
awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js
Normal file
107
awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js
Normal file
@ -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 += `<span class="time">${time}</span>`;
|
||||
}
|
||||
|
||||
lineTextHtml.push({
|
||||
lineNumber: start_line + index,
|
||||
html,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
lineCssMap,
|
||||
lineTextHtml,
|
||||
};
|
||||
}
|
||||
16
awx/ui/src/screens/Job/JobOutput/isHostEvent.js
Normal file
16
awx/ui/src/screens/Job/JobOutput/isHostEvent.js
Normal file
@ -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;
|
||||
}
|
||||
46
awx/ui/src/screens/Job/JobOutput/loadJobEvents.js
Normal file
46
awx/ui/src/screens/Job/JobOutput/loadJobEvents.js
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user