Merge pull request #10909 from keithjgrant/6613-job-output-collapsing

Refactor Job Output component
This commit is contained in:
Tiago Góes 2021-08-31 10:43:22 -03:00 committed by GitHub
commit c095f0fc19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 502 additions and 448 deletions

View File

@ -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;

View 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;

View File

@ -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,

View 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();
}
}

View 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;
}

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

View 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;
}

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