mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Job Output expand/collapse take 2 (#11312)
This commit is contained in:
parent
a259e48377
commit
675d0d28d2
@ -18,6 +18,7 @@ import InventorySources from './models/InventorySources';
|
||||
import InventoryUpdates from './models/InventoryUpdates';
|
||||
import JobTemplates from './models/JobTemplates';
|
||||
import Jobs from './models/Jobs';
|
||||
import JobEvents from './models/JobEvents';
|
||||
import Labels from './models/Labels';
|
||||
import Me from './models/Me';
|
||||
import Metrics from './models/Metrics';
|
||||
@ -63,6 +64,7 @@ const InventorySourcesAPI = new InventorySources();
|
||||
const InventoryUpdatesAPI = new InventoryUpdates();
|
||||
const JobTemplatesAPI = new JobTemplates();
|
||||
const JobsAPI = new Jobs();
|
||||
const JobEventsAPI = new JobEvents();
|
||||
const LabelsAPI = new Labels();
|
||||
const MeAPI = new Me();
|
||||
const MetricsAPI = new Metrics();
|
||||
@ -109,6 +111,7 @@ export {
|
||||
InventoryUpdatesAPI,
|
||||
JobTemplatesAPI,
|
||||
JobsAPI,
|
||||
JobEventsAPI,
|
||||
LabelsAPI,
|
||||
MeAPI,
|
||||
MetricsAPI,
|
||||
|
||||
14
awx/ui/src/api/models/JobEvents.js
Normal file
14
awx/ui/src/api/models/JobEvents.js
Normal file
@ -0,0 +1,14 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class JobEvents extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/job_events/';
|
||||
}
|
||||
|
||||
readChildren(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/children/`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export default JobEvents;
|
||||
@ -1,42 +1,68 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
JobEventLine,
|
||||
JobEventLineToggle,
|
||||
JobEventLineNumber,
|
||||
JobEventLineText,
|
||||
JobEventEllipsis,
|
||||
} from './shared';
|
||||
|
||||
function JobEvent({
|
||||
counter,
|
||||
stdout,
|
||||
style,
|
||||
type,
|
||||
lineTextHtml,
|
||||
isClickable,
|
||||
onJobEventClick,
|
||||
event,
|
||||
measure,
|
||||
isCollapsed,
|
||||
onToggleCollapsed,
|
||||
hasChildren,
|
||||
}) {
|
||||
return !stdout ? null : (
|
||||
<div style={style} type={type}>
|
||||
{lineTextHtml.map(
|
||||
({ lineNumber, html }) =>
|
||||
lineNumber >= 0 && (
|
||||
<JobEventLine
|
||||
onClick={isClickable ? onJobEventClick : undefined}
|
||||
key={`${counter}-${lineNumber}`}
|
||||
isFirst={lineNumber === 0}
|
||||
isClickable={isClickable}
|
||||
>
|
||||
<JobEventLineToggle />
|
||||
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
|
||||
<JobEventLineText
|
||||
type="job_event_line_text"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
/>
|
||||
</JobEventLine>
|
||||
)
|
||||
)}
|
||||
const numOutputLines = lineTextHtml?.length || 0;
|
||||
useEffect(() => {
|
||||
measure();
|
||||
}, [numOutputLines, isCollapsed, measure]);
|
||||
|
||||
let toggleLineIndex = -1;
|
||||
if (hasChildren) {
|
||||
lineTextHtml.forEach(({ html }, index) => {
|
||||
if (html) {
|
||||
toggleLineIndex = index;
|
||||
}
|
||||
});
|
||||
}
|
||||
return !event.stdout ? null : (
|
||||
<div style={style} type={event.type}>
|
||||
{lineTextHtml.map(({ lineNumber, html }, index) => {
|
||||
if (lineNumber < 0) {
|
||||
return null;
|
||||
}
|
||||
const canToggle = index === toggleLineIndex;
|
||||
return (
|
||||
<JobEventLine
|
||||
onClick={isClickable ? onJobEventClick : undefined}
|
||||
key={`${event.counter}-${lineNumber}`}
|
||||
isFirst={lineNumber === 0}
|
||||
isClickable={isClickable}
|
||||
>
|
||||
<JobEventLineToggle
|
||||
canToggle={canToggle}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapsed}
|
||||
/>
|
||||
<JobEventLineNumber>
|
||||
{lineNumber}
|
||||
<JobEventEllipsis isCollapsed={isCollapsed && canToggle} />
|
||||
</JobEventLineNumber>
|
||||
<JobEventLineText
|
||||
type="job_event_line_text"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
/>
|
||||
</JobEventLine>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { shallow } from 'enzyme';
|
||||
import JobEvent from './JobEvent';
|
||||
|
||||
const mockOnPlayStartEvent = {
|
||||
@ -19,9 +19,6 @@ const mockRunnerOnOkEvent = {
|
||||
end_line: 5,
|
||||
stdout: '\u001b[0;32mok: [localhost]\u001b[0m',
|
||||
};
|
||||
const selectors = {
|
||||
lineText: 'JobEventLineText',
|
||||
};
|
||||
|
||||
const singleDigitTimestampEvent = {
|
||||
...mockOnPlayStartEvent,
|
||||
@ -52,55 +49,51 @@ const mockOnPlayStartLineTextHtml = [
|
||||
];
|
||||
|
||||
describe('<JobEvent />', () => {
|
||||
test('initially renders successfully', () => {
|
||||
mountWithContexts(
|
||||
<JobEvent
|
||||
lineTextHtml={mockOnPlayStartLineTextHtml}
|
||||
{...mockOnPlayStartEvent}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('playbook event timestamps are rendered', () => {
|
||||
let wrapper = mountWithContexts(
|
||||
const wrapper1 = shallow(
|
||||
<JobEvent
|
||||
lineTextHtml={mockOnPlayStartLineTextHtml}
|
||||
{...mockOnPlayStartEvent}
|
||||
event={mockOnPlayStartEvent}
|
||||
/>
|
||||
);
|
||||
let lineText = wrapper.find(selectors.lineText);
|
||||
expect(
|
||||
lineText.filterWhere((e) => e.text().includes('18:11:22'))
|
||||
).toHaveLength(1);
|
||||
const lineText1 = wrapper1.find('JobEventLineText');
|
||||
const html1 = lineText1.at(1).prop('dangerouslySetInnerHTML').__html;
|
||||
expect(html1.includes('18:11:22')).toBe(true);
|
||||
|
||||
wrapper = mountWithContexts(
|
||||
const wrapper2 = shallow(
|
||||
<JobEvent
|
||||
lineTextHtml={mockSingleDigitTimestampEventLineTextHtml}
|
||||
{...singleDigitTimestampEvent}
|
||||
event={singleDigitTimestampEvent}
|
||||
/>
|
||||
);
|
||||
lineText = wrapper.find(selectors.lineText);
|
||||
expect(
|
||||
lineText.filterWhere((e) => e.text().includes('08:01:02'))
|
||||
).toHaveLength(1);
|
||||
const lineText2 = wrapper2.find('JobEventLineText');
|
||||
const html2 = lineText2.at(1).prop('dangerouslySetInnerHTML').__html;
|
||||
expect(html2.includes('08:01:02')).toBe(true);
|
||||
});
|
||||
|
||||
test('ansi stdout colors are rendered as html', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<JobEvent lineTextHtml={mockAnsiLineTextHtml} {...mockRunnerOnOkEvent} />
|
||||
const wrapper = shallow(
|
||||
<JobEvent
|
||||
lineTextHtml={mockAnsiLineTextHtml}
|
||||
event={mockRunnerOnOkEvent}
|
||||
/>
|
||||
);
|
||||
const lineText = wrapper.find(selectors.lineText);
|
||||
const lineText = wrapper.find('JobEventLineText');
|
||||
expect(
|
||||
lineText
|
||||
.html()
|
||||
.includes('<span class="output--1977390340">ok: [localhost]</span>')
|
||||
.prop('dangerouslySetInnerHTML')
|
||||
.__html.includes(
|
||||
'<span class="output--1977390340">ok: [localhost]</span>'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("events without stdout aren't rendered", () => {
|
||||
const missingStdoutEvent = { ...mockOnPlayStartEvent };
|
||||
delete missingStdoutEvent.stdout;
|
||||
const wrapper = mountWithContexts(<JobEvent {...missingStdoutEvent} />);
|
||||
expect(wrapper.find(selectors.lineText)).toHaveLength(0);
|
||||
const wrapper = shallow(
|
||||
<JobEvent lineTextHtml={[]} event={missingStdoutEvent} />
|
||||
);
|
||||
expect(wrapper.find('JobEventLineText')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
JobEventLine,
|
||||
JobEventLineToggle,
|
||||
@ -14,7 +14,11 @@ function JobEventSkeletonContent({ contentLength }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JobEventSkeleton({ counter, contentLength, style }) {
|
||||
function JobEventSkeleton({ counter, contentLength, style, measure }) {
|
||||
useEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
counter > 1 && (
|
||||
<div style={style}>
|
||||
|
||||
@ -5,17 +5,17 @@ import JobEventSkeleton from './JobEventSkeleton';
|
||||
|
||||
const contentSelector = 'JobEventSkeletonContent';
|
||||
|
||||
describe('<JobEvenSkeletont />', () => {
|
||||
describe('<JobEvenSkeleton />', () => {
|
||||
test('initially renders successfully', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<JobEventSkeleton contentLength={80} counter={100} />
|
||||
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={100} />
|
||||
);
|
||||
expect(wrapper.find(contentSelector).length).toEqual(1);
|
||||
});
|
||||
|
||||
test('always skips first counter', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<JobEventSkeleton contentLength={80} counter={1} />
|
||||
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={1} />
|
||||
);
|
||||
expect(wrapper.find(contentSelector).length).toEqual(0);
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import StatusIcon from 'components/StatusIcon';
|
||||
import { JobEventsAPI } from 'api';
|
||||
|
||||
import { getJobModel, isJobRunning } from 'util/jobs';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
@ -33,7 +34,8 @@ import getLineTextHtml from './getLineTextHtml';
|
||||
import connectJobSocket, { closeWebSocket } from './connectJobSocket';
|
||||
import getEventRequestParams from './getEventRequestParams';
|
||||
import isHostEvent from './isHostEvent';
|
||||
import { fetchCount, normalizeEvents } from './loadJobEvents';
|
||||
import { prependTraceback } from './loadJobEvents';
|
||||
import useJobEvents from './useJobEvents';
|
||||
|
||||
const QS_CONFIG = getQSConfig('job_output', {
|
||||
order_by: 'counter',
|
||||
@ -94,20 +96,113 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
const scrollTop = useRef(0);
|
||||
const scrollHeight = useRef(0);
|
||||
const history = useHistory();
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const eventByUuidRequests = useRef([]);
|
||||
const siblingRequests = useRef([]);
|
||||
const numEventsRequests = useRef([]);
|
||||
|
||||
const fetchEventByUuid = async (uuid) => {
|
||||
let promise = eventByUuidRequests.current[uuid];
|
||||
if (!promise) {
|
||||
promise = getJobModel(job.type).readEvents(job.id, { uuid });
|
||||
eventByUuidRequests.current[uuid] = promise;
|
||||
}
|
||||
const { data } = await promise;
|
||||
eventByUuidRequests.current[uuid] = null;
|
||||
return data.results[0] || null;
|
||||
};
|
||||
|
||||
const fetchNextSibling = async (parentEventId, counter) => {
|
||||
const key = `${parentEventId}-${counter}`;
|
||||
let promise = siblingRequests.current[key];
|
||||
if (!promise) {
|
||||
promise = JobEventsAPI.readChildren(parentEventId, {
|
||||
page_size: 1,
|
||||
order_by: 'counter',
|
||||
counter__gt: counter,
|
||||
});
|
||||
siblingRequests.current[key] = promise;
|
||||
}
|
||||
|
||||
const { data } = await promise;
|
||||
siblingRequests.current[key] = null;
|
||||
return data.results[0] || null;
|
||||
};
|
||||
|
||||
const fetchNextRootNode = async (counter) => {
|
||||
const { data } = await getJobModel(job.type).readEvents(job.id, {
|
||||
page_size: 1,
|
||||
order_by: 'counter',
|
||||
counter__gt: counter,
|
||||
parent_uuid: '',
|
||||
});
|
||||
return data.results[0] || null;
|
||||
};
|
||||
|
||||
const fetchNumEvents = async (startCounter, endCounter) => {
|
||||
if (endCounter <= startCounter + 1) {
|
||||
return 0;
|
||||
}
|
||||
const key = `${startCounter}-${endCounter}`;
|
||||
let promise = numEventsRequests.current[key];
|
||||
if (!promise) {
|
||||
const params = {
|
||||
page_size: 1,
|
||||
order_by: 'counter',
|
||||
counter__gt: startCounter,
|
||||
};
|
||||
if (endCounter) {
|
||||
params.counter__lt = endCounter;
|
||||
}
|
||||
promise = getJobModel(job.type).readEvents(job.id, params);
|
||||
numEventsRequests.current[key] = promise;
|
||||
}
|
||||
|
||||
const { data } = await promise;
|
||||
numEventsRequests.current[key] = null;
|
||||
return data.count || 0;
|
||||
};
|
||||
|
||||
const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting');
|
||||
const isFlatMode = isJobRunning(jobStatus) || location.search.length > 1;
|
||||
|
||||
const {
|
||||
addEvents,
|
||||
toggleNodeIsCollapsed,
|
||||
getEventForRow,
|
||||
getNumCollapsedEvents,
|
||||
getCounterForRow,
|
||||
getEvent,
|
||||
clearLoadedEvents,
|
||||
rebuildEventsTree,
|
||||
} = useJobEvents(
|
||||
{
|
||||
fetchEventByUuid,
|
||||
fetchNextSibling,
|
||||
fetchNextRootNode,
|
||||
fetchNumEvents,
|
||||
},
|
||||
isFlatMode
|
||||
);
|
||||
const [wsEvents, setWsEvents] = useState([]);
|
||||
const [cssMap, setCssMap] = useState({});
|
||||
const [remoteRowCount, setRemoteRowCount] = useState(0);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [currentlyLoading, setCurrentlyLoading] = useState([]);
|
||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||
const [hostEvent, setHostEvent] = useState({});
|
||||
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
|
||||
const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting');
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
const [remoteRowCount, setRemoteRowCount] = useState(0);
|
||||
const [results, setResults] = useState({});
|
||||
const [highestLoadedCounter, setHighestLoadedCounter] = useState(0);
|
||||
const [isFollowModeEnabled, setIsFollowModeEnabled] = useState(
|
||||
isJobRunning(job.status)
|
||||
);
|
||||
const [isMonitoringWebsocket, setIsMonitoringWebsocket] = useState(false);
|
||||
const [lastScrollPosition, setLastScrollPosition] = useState(0);
|
||||
|
||||
const totalNonCollapsedRows = Math.max(
|
||||
remoteRowCount - getNumCollapsedEvents(),
|
||||
0
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
@ -117,53 +212,116 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobEvents();
|
||||
const pendingRequests = [
|
||||
...Object.values(eventByUuidRequests.current || {}),
|
||||
...Object.values(siblingRequests.current || {}),
|
||||
...Object.values(numEventsRequests.current || {}),
|
||||
];
|
||||
Promise.all(pendingRequests).then(() => {
|
||||
setRemoteRowCount(0);
|
||||
clearLoadedEvents();
|
||||
loadJobEvents();
|
||||
});
|
||||
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isJobRunning(job.status)) {
|
||||
connectJobSocket(job, (data) => {
|
||||
if (data.group_name === 'job_events') {
|
||||
if (data.counter && data.counter > jobSocketCounter.current) {
|
||||
jobSocketCounter.current = data.counter;
|
||||
}
|
||||
}
|
||||
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
|
||||
if (data.final_counter) {
|
||||
jobSocketCounter.current = data.final_counter;
|
||||
}
|
||||
if (data.status) {
|
||||
setJobStatus(data.status);
|
||||
}
|
||||
}
|
||||
});
|
||||
setIsMonitoringWebsocket(true);
|
||||
useEffect(() => {
|
||||
if (!isJobRunning(jobStatus)) {
|
||||
setIsFollowModeEnabled(false);
|
||||
}
|
||||
rebuildEventsTree();
|
||||
}, [isFlatMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!isJobRunning(jobStatus)) {
|
||||
setTimeout(() => {
|
||||
loadJobEvents().then(() => {
|
||||
setWsEvents([]);
|
||||
scrollToRow(lastScrollPosition);
|
||||
});
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
let batchTimeout;
|
||||
let batchedEvents = [];
|
||||
connectJobSocket(job, (data) => {
|
||||
const addBatchedEvents = () => {
|
||||
let min;
|
||||
let max;
|
||||
let newCssMap;
|
||||
batchedEvents.forEach((event) => {
|
||||
if (!min || event.counter < min) {
|
||||
min = event.counter;
|
||||
}
|
||||
if (!max || event.counter > max) {
|
||||
max = event.counter;
|
||||
}
|
||||
const { lineCssMap } = getLineTextHtml(event);
|
||||
newCssMap = {
|
||||
...newCssMap,
|
||||
...lineCssMap,
|
||||
};
|
||||
});
|
||||
setWsEvents((oldWsEvents) => {
|
||||
const updated = oldWsEvents.concat(batchedEvents);
|
||||
jobSocketCounter.current = updated.length;
|
||||
return updated.sort((a, b) => a.counter - b.counter);
|
||||
});
|
||||
setCssMap((prevCssMap) => ({
|
||||
...prevCssMap,
|
||||
...newCssMap,
|
||||
}));
|
||||
if (max > jobSocketCounter.current) {
|
||||
jobSocketCounter.current = max;
|
||||
}
|
||||
batchedEvents = [];
|
||||
};
|
||||
|
||||
if (data.group_name === 'job_events') {
|
||||
batchedEvents.push(data);
|
||||
clearTimeout(batchTimeout);
|
||||
if (batchedEvents.length >= 25) {
|
||||
addBatchedEvents();
|
||||
} else {
|
||||
batchTimeout = setTimeout(addBatchedEvents, 500);
|
||||
}
|
||||
}
|
||||
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
|
||||
if (data.final_counter) {
|
||||
jobSocketCounter.current = data.final_counter;
|
||||
}
|
||||
if (data.status) {
|
||||
setJobStatus(data.status);
|
||||
}
|
||||
}
|
||||
});
|
||||
setIsMonitoringWebsocket(true);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return function cleanup() {
|
||||
clearTimeout(batchTimeout);
|
||||
closeWebSocket();
|
||||
setIsMonitoringWebsocket(false);
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isJobRunning(jobStatus)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current?.recomputeRowHeights) {
|
||||
listRef.current.recomputeRowHeights();
|
||||
}
|
||||
}, [currentlyLoading, cssMap, remoteRowCount]);
|
||||
}, [currentlyLoading, cssMap, remoteRowCount, wsEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (jobStatus && !isJobRunning(jobStatus)) {
|
||||
if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
|
||||
setRemoteRowCount(jobSocketCounter.current);
|
||||
}
|
||||
if (!jobStatus || isJobRunning(jobStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMonitoringWebsocket) {
|
||||
setIsMonitoringWebsocket(false);
|
||||
}
|
||||
if (isMonitoringWebsocket) {
|
||||
setIsMonitoringWebsocket(false);
|
||||
}
|
||||
|
||||
if (isFollowModeEnabled) {
|
||||
setTimeout(() => setIsFollowModeEnabled(false), 1000);
|
||||
}
|
||||
if (isFollowModeEnabled) {
|
||||
setTimeout(() => setIsFollowModeEnabled(false), 1000);
|
||||
}
|
||||
}, [jobStatus]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@ -197,9 +355,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
useDismissableError(deleteError);
|
||||
|
||||
const monitorJobSocketCounter = () => {
|
||||
if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
|
||||
setRemoteRowCount(jobSocketCounter.current);
|
||||
}
|
||||
if (
|
||||
jobSocketCounter.current === remoteRowCount &&
|
||||
!isJobRunning(job.status)
|
||||
@ -218,34 +373,43 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFlatMode) {
|
||||
params.not__stdout = '';
|
||||
}
|
||||
const qsParams = parseQueryString(QS_CONFIG, location.search);
|
||||
const eventPromise = getJobModel(job.type).readEvents(job.id, {
|
||||
...params,
|
||||
...parseQueryString(QS_CONFIG, location.search),
|
||||
...qsParams,
|
||||
});
|
||||
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: { results: fetchedEvents = [] },
|
||||
},
|
||||
count,
|
||||
] = await Promise.all([eventPromise, fetchCount(job, eventPromise)]);
|
||||
const {
|
||||
data: { count, results: fetchedEvents = [] },
|
||||
} = await eventPromise;
|
||||
|
||||
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 };
|
||||
let newCssMap;
|
||||
let rowNumber = 0;
|
||||
const { events, countOffset } = prependTraceback(job, fetchedEvents);
|
||||
events.forEach((event) => {
|
||||
event.rowNumber = rowNumber;
|
||||
rowNumber++;
|
||||
const { lineCssMap } = getLineTextHtml(event);
|
||||
newCssMap = {
|
||||
...newCssMap,
|
||||
...lineCssMap,
|
||||
};
|
||||
});
|
||||
setResults(newResults);
|
||||
setCssMap((prevCssMap) => ({
|
||||
...prevCssMap,
|
||||
...newCssMap,
|
||||
}));
|
||||
const lastCounter = events[events.length - 1]?.counter || 50;
|
||||
addEvents(events);
|
||||
setHighestLoadedCounter(lastCounter);
|
||||
setRemoteRowCount(count + countOffset);
|
||||
setCssMap(newResultsCssMap);
|
||||
} catch (err) {
|
||||
setContentError(err);
|
||||
} finally {
|
||||
@ -262,10 +426,20 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
};
|
||||
|
||||
const isRowLoaded = ({ index }) => {
|
||||
if (results[index]) {
|
||||
let counter;
|
||||
try {
|
||||
counter = getCounterForRow(index);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
return false;
|
||||
}
|
||||
if (getEvent(counter)) {
|
||||
return true;
|
||||
}
|
||||
return currentlyLoading.includes(index);
|
||||
if (index > remoteRowCount && index < remoteRowCount + wsEvents.length) {
|
||||
return true;
|
||||
}
|
||||
return currentlyLoading.includes(counter);
|
||||
};
|
||||
|
||||
const handleHostEventClick = (hostEventToOpen) => {
|
||||
@ -281,9 +455,30 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
if (listRef.current && isFollowModeEnabled) {
|
||||
setTimeout(() => scrollToRow(remoteRowCount - 1), 0);
|
||||
}
|
||||
let event;
|
||||
let node;
|
||||
try {
|
||||
const eventForRow = getEventForRow(index) || {};
|
||||
event = eventForRow.event;
|
||||
node = eventForRow.node;
|
||||
} catch (e) {
|
||||
event = null;
|
||||
}
|
||||
if (
|
||||
!event &&
|
||||
index > remoteRowCount &&
|
||||
index < remoteRowCount + wsEvents.length
|
||||
) {
|
||||
event = wsEvents[index - remoteRowCount];
|
||||
node = {
|
||||
eventIndex: event?.counter,
|
||||
isCollapsed: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
let actualLineTextHtml = [];
|
||||
if (results[index]) {
|
||||
const { lineTextHtml } = getLineTextHtml(results[index]);
|
||||
if (event) {
|
||||
const { lineTextHtml } = getLineTextHtml(event);
|
||||
actualLineTextHtml = lineTextHtml;
|
||||
}
|
||||
|
||||
@ -295,86 +490,120 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
rowIndex={index}
|
||||
columnIndex={0}
|
||||
>
|
||||
{results[index] ? (
|
||||
<JobEvent
|
||||
isClickable={isHostEvent(results[index])}
|
||||
onJobEventClick={() => handleHostEventClick(results[index])}
|
||||
className="row"
|
||||
style={style}
|
||||
lineTextHtml={actualLineTextHtml}
|
||||
index={index}
|
||||
{...results[index]}
|
||||
/>
|
||||
) : (
|
||||
<JobEventSkeleton
|
||||
className="row"
|
||||
style={style}
|
||||
counter={index}
|
||||
contentLength={80}
|
||||
/>
|
||||
)}
|
||||
{({ measure }) =>
|
||||
event ? (
|
||||
<JobEvent
|
||||
isClickable={isHostEvent(event)}
|
||||
onJobEventClick={() => handleHostEventClick(event)}
|
||||
className="row"
|
||||
style={style}
|
||||
lineTextHtml={actualLineTextHtml}
|
||||
index={index}
|
||||
event={event}
|
||||
measure={measure}
|
||||
isCollapsed={node.isCollapsed}
|
||||
hasChildren={node.children.length}
|
||||
onToggleCollapsed={() => {
|
||||
toggleNodeIsCollapsed(event.uuid);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<JobEventSkeleton
|
||||
className="row"
|
||||
style={style}
|
||||
counter={index}
|
||||
contentLength={80}
|
||||
measure={measure}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</CellMeasurer>
|
||||
);
|
||||
};
|
||||
|
||||
const loadMoreRows = ({ startIndex, stopIndex }) => {
|
||||
const loadMoreRows = async ({ startIndex, stopIndex }) => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
if (startIndex === 0 && stopIndex === 0) {
|
||||
return Promise.resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopIndex > startIndex + 50) {
|
||||
stopIndex = startIndex + 50;
|
||||
}
|
||||
|
||||
const [requestParams, loadRange, firstIndex] = getEventRequestParams(
|
||||
job,
|
||||
remoteRowCount,
|
||||
[startIndex, stopIndex]
|
||||
);
|
||||
|
||||
if (isMounted.current) {
|
||||
setCurrentlyLoading((prevCurrentlyLoading) =>
|
||||
prevCurrentlyLoading.concat(loadRange)
|
||||
);
|
||||
}
|
||||
|
||||
let range = [startIndex, stopIndex];
|
||||
if (!isFlatMode) {
|
||||
const diff = stopIndex - startIndex;
|
||||
const startCounter = getCounterForRow(startIndex);
|
||||
range = [startCounter, startCounter + diff];
|
||||
}
|
||||
|
||||
const [requestParams, loadRange] = getEventRequestParams(
|
||||
job,
|
||||
remoteRowCount,
|
||||
range
|
||||
);
|
||||
const qs = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = {
|
||||
...requestParams,
|
||||
...parseQueryString(QS_CONFIG, location.search),
|
||||
...qs,
|
||||
};
|
||||
if (isFlatMode) {
|
||||
params.not__stdout = '';
|
||||
}
|
||||
|
||||
return getJobModel(job.type)
|
||||
.readEvents(job.id, params)
|
||||
.then((response) => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
const model = getJobModel(job.type);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await model.readEvents(job.id, params);
|
||||
} catch (error) {
|
||||
if (error.response.status === 404) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
const events = response.data.results;
|
||||
const firstIndex = (params.page - 1) * params.page_size;
|
||||
|
||||
let newCssMap;
|
||||
let rowNumber = firstIndex;
|
||||
events.forEach((event) => {
|
||||
event.rowNumber = rowNumber;
|
||||
rowNumber++;
|
||||
const { lineCssMap } = getLineTextHtml(event);
|
||||
newCssMap = {
|
||||
...newCssMap,
|
||||
...lineCssMap,
|
||||
};
|
||||
});
|
||||
setCssMap((prevCssMap) => ({
|
||||
...prevCssMap,
|
||||
...newCssMap,
|
||||
}));
|
||||
|
||||
const lastCounter = events[events.length - 1]?.counter || 50;
|
||||
addEvents(events);
|
||||
if (lastCounter > highestLoadedCounter) {
|
||||
setHighestLoadedCounter(lastCounter);
|
||||
}
|
||||
setCurrentlyLoading((prevCurrentlyLoading) =>
|
||||
prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
|
||||
);
|
||||
loadRange.forEach((n) => {
|
||||
cache.clear(n);
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToRow = (rowIndex) => {
|
||||
setLastScrollPosition(rowIndex);
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollToRow(rowIndex);
|
||||
}
|
||||
@ -385,6 +614,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
|
||||
const scrollRange = stopIndex - startIndex + 1;
|
||||
scrollToRow(Math.max(0, startIndex - scrollRange));
|
||||
setIsFollowModeEnabled(false);
|
||||
};
|
||||
|
||||
const handleScrollNext = () => {
|
||||
@ -394,10 +624,11 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
|
||||
const handleScrollFirst = () => {
|
||||
scrollToRow(0);
|
||||
setIsFollowModeEnabled(false);
|
||||
};
|
||||
|
||||
const handleScrollLast = () => {
|
||||
scrollToRow(remoteRowCount - 1);
|
||||
scrollToRow(totalNonCollapsedRows + wsEvents.length);
|
||||
};
|
||||
|
||||
const handleResize = ({ width }) => {
|
||||
@ -470,7 +701,8 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
<InfiniteLoader
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={remoteRowCount}
|
||||
rowCount={totalNonCollapsedRows + wsEvents.length}
|
||||
minimumBatchSize={50}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => (
|
||||
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
|
||||
@ -489,7 +721,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
deferredMeasurementCache={cache}
|
||||
height={height || 1}
|
||||
onRowsRendered={onRowsRendered}
|
||||
rowCount={remoteRowCount}
|
||||
rowCount={totalNonCollapsedRows + wsEvents.length}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-len */
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { JobsAPI } from 'api';
|
||||
import { JobsAPI, JobEventsAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@ -9,7 +9,6 @@ import {
|
||||
import JobOutput from './JobOutput';
|
||||
import mockJobData from '../shared/data.job.json';
|
||||
import mockJobEventsData from './data.job_events.json';
|
||||
import mockFilteredJobEventsData from './data.filtered_job_events.json';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
@ -27,73 +26,17 @@ const applyJobEventMock = (mockJobEvents) => {
|
||||
};
|
||||
};
|
||||
JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents);
|
||||
};
|
||||
|
||||
const generateChattyRows = () => {
|
||||
const rows = [
|
||||
'',
|
||||
'PLAY [all] *********************************************************************16:17:13',
|
||||
'',
|
||||
'TASK [debug] *******************************************************************16:17:13',
|
||||
];
|
||||
|
||||
for (let i = 1; i < 95; i++) {
|
||||
rows.push(
|
||||
`ok: [localhost] => (item=${i}) => {`,
|
||||
` "msg": "This is a debug message: ${i}"`,
|
||||
'}'
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(
|
||||
'',
|
||||
'PLAY RECAP *********************************************************************16:17:15',
|
||||
'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ',
|
||||
''
|
||||
);
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
async function checkOutput(wrapper, expectedLines) {
|
||||
await waitForElement(wrapper, 'div[type="job_event"]', (el) => el.length > 1);
|
||||
const jobEventLines = wrapper.find('JobEventLineText div');
|
||||
const actualLines = [];
|
||||
jobEventLines.forEach((line) => {
|
||||
actualLines.push(line.text());
|
||||
JobEventsAPI.readChildren = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
counter: 20,
|
||||
uuid: 'abc-020',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find('JobEvent[event="playbook_on_stats"]').prop('end_line')
|
||||
).toEqual(expectedLines.length);
|
||||
}
|
||||
|
||||
async function findScrollButtons(wrapper) {
|
||||
const pageControls = await waitForElement(wrapper, 'PageControls');
|
||||
const scrollFirstButton = pageControls.find(
|
||||
'button[aria-label="Scroll first"]'
|
||||
);
|
||||
const scrollLastButton = pageControls.find(
|
||||
'button[aria-label="Scroll last"]'
|
||||
);
|
||||
const scrollPreviousButton = pageControls.find(
|
||||
'button[aria-label="Scroll previous"]'
|
||||
);
|
||||
return {
|
||||
scrollFirstButton,
|
||||
scrollLastButton,
|
||||
scrollPreviousButton,
|
||||
};
|
||||
}
|
||||
|
||||
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'offsetHeight'
|
||||
);
|
||||
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'offsetWidth'
|
||||
);
|
||||
};
|
||||
|
||||
describe('<JobOutput />', () => {
|
||||
let wrapper;
|
||||
@ -101,88 +44,18 @@ describe('<JobOutput />', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
applyJobEventMock(mockJobEventsData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0);
|
||||
await checkOutput(wrapper, generateChattyRows());
|
||||
|
||||
expect(wrapper.find('JobOutput').length).toBe(1);
|
||||
});
|
||||
|
||||
test('navigation buttons should display output properly', async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 10,
|
||||
value: 200,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0);
|
||||
const { scrollFirstButton, scrollLastButton, scrollPreviousButton } =
|
||||
await findScrollButtons(wrapper);
|
||||
let jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe('');
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\r\nPLAY [all] *********************************************************************'
|
||||
);
|
||||
await act(async () => {
|
||||
scrollLastButton.simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
|
||||
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
|
||||
);
|
||||
await act(async () => {
|
||||
scrollPreviousButton.simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
await act(async () => {
|
||||
scrollFirstButton.simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe('');
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\r\nPLAY [all] *********************************************************************'
|
||||
);
|
||||
await act(async () => {
|
||||
scrollLastButton.simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
|
||||
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
|
||||
);
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'offsetHeight',
|
||||
originalOffsetHeight
|
||||
);
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'offsetWidth',
|
||||
originalOffsetWidth
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should make expected api call for delete', async () => {
|
||||
@ -260,33 +133,6 @@ describe('<JobOutput />', () => {
|
||||
expect(wrapper.find('Search').props().isDisabled).toBe(true);
|
||||
});
|
||||
|
||||
test('filter should trigger api call and display correct rows', async () => {
|
||||
const searchBtn = 'button[aria-label="Search submit button"]';
|
||||
const searchTextInput = 'input[aria-label="Search text input"]';
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0);
|
||||
applyJobEventMock(mockFilteredJobEventsData);
|
||||
await act(async () => {
|
||||
wrapper.find(searchTextInput).instance().value = '99';
|
||||
wrapper.find(searchTextInput).simulate('change');
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find(searchBtn).simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(JobsAPI.readEvents).toHaveBeenCalled();
|
||||
const jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error', async () => {
|
||||
JobsAPI.readEvents = () => Promise.reject(new Error());
|
||||
await act(async () => {
|
||||
|
||||
48
awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js
Normal file
48
awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import JobOutputSearch from './JobOutputSearch';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
history: () => ({
|
||||
location: '/jobs/playbook/1/output',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('JobOutputSearch', () => {
|
||||
test('should update url query params', async () => {
|
||||
const searchBtn = 'button[aria-label="Search submit button"]';
|
||||
const searchTextInput = 'input[aria-label="Search text input"]';
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/jobs/playbook/1/output'],
|
||||
});
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<JobOutputSearch
|
||||
job={{
|
||||
status: 'successful',
|
||||
}}
|
||||
qsConfig={{
|
||||
defaultParams: { page: 1 },
|
||||
integerFields: ['page', 'page_size'],
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find(searchTextInput).instance().value = '99';
|
||||
wrapper.find(searchTextInput).simulate('change');
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find(searchBtn).simulate('click');
|
||||
});
|
||||
|
||||
expect(history.location.search).toEqual('?stdout__icontains=99');
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,3 @@
|
||||
import { isJobRunning } from 'util/jobs';
|
||||
import getRowRangePageSize from './shared/jobOutputUtils';
|
||||
|
||||
export default function getEventRequestParams(
|
||||
@ -7,26 +6,19 @@ export default function getEventRequestParams(
|
||||
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,
|
||||
firstIndex + 1,
|
||||
Math.min(firstIndex + pageSize, remoteRowCount)
|
||||
);
|
||||
|
||||
return [{ page, page_size: pageSize }, loadRange, firstIndex];
|
||||
return [{ page, page_size: pageSize }, loadRange];
|
||||
}
|
||||
|
||||
function range(low, high) {
|
||||
export function range(low, high) {
|
||||
const numbers = [];
|
||||
for (let n = low; n <= high; n++) {
|
||||
numbers.push(n);
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import getEventRequestParams, { range } from './getEventRequestParams';
|
||||
|
||||
describe('getEventRequestParams', () => {
|
||||
const job = {
|
||||
status: 'successful',
|
||||
};
|
||||
|
||||
it('should return first page', () => {
|
||||
const [params, loadRange] = getEventRequestParams(job, 50, [1, 50]);
|
||||
|
||||
expect(params).toEqual({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
expect(loadRange).toEqual(range(1, 50));
|
||||
});
|
||||
|
||||
it('should return second page', () => {
|
||||
const [params, loadRange] = getEventRequestParams(job, 1000, [51, 100]);
|
||||
|
||||
expect(params).toEqual({
|
||||
page: 2,
|
||||
page_size: 50,
|
||||
});
|
||||
expect(loadRange).toEqual(range(51, 100));
|
||||
});
|
||||
|
||||
it('should return page for first portion of requested range', () => {
|
||||
const [params, loadRange] = getEventRequestParams(job, 1000, [75, 125]);
|
||||
|
||||
expect(params).toEqual({
|
||||
page: 2,
|
||||
page_size: 50,
|
||||
});
|
||||
expect(loadRange).toEqual(range(51, 100));
|
||||
});
|
||||
|
||||
it('should return smaller page for shorter range', () => {
|
||||
const [params, loadRange] = getEventRequestParams(job, 1000, [120, 125]);
|
||||
|
||||
expect(params).toEqual({
|
||||
page: 21,
|
||||
page_size: 6,
|
||||
});
|
||||
expect(loadRange).toEqual(range(121, 126));
|
||||
});
|
||||
});
|
||||
@ -72,7 +72,7 @@ function replaceStyleAttrs(html) {
|
||||
export default function getLineTextHtml({
|
||||
created,
|
||||
event,
|
||||
start_line,
|
||||
start_line: startLine,
|
||||
stdout,
|
||||
}) {
|
||||
const sanitized = encode(stdout);
|
||||
@ -95,7 +95,7 @@ export default function getLineTextHtml({
|
||||
}
|
||||
|
||||
lineTextHtml.push({
|
||||
lineNumber: start_line + index,
|
||||
lineNumber: startLine + index,
|
||||
html,
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,42 +1,41 @@
|
||||
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;
|
||||
}
|
||||
import { getJobModel } from 'util/jobs';
|
||||
|
||||
export async function fetchCount(job, params) {
|
||||
const {
|
||||
data: { count: eventCount },
|
||||
} = await eventPromise;
|
||||
return eventCount;
|
||||
data: { results: lastEvents = [] },
|
||||
} = await getJobModel(job.type).readEvents(job.id, {
|
||||
...params,
|
||||
order_by: '-counter',
|
||||
limit: 1,
|
||||
});
|
||||
return lastEvents.length >= 1 ? lastEvents[0].counter : 0;
|
||||
}
|
||||
|
||||
export function normalizeEvents(job, events) {
|
||||
export function prependTraceback(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,
|
||||
if (!job?.result_traceback) {
|
||||
return {
|
||||
events,
|
||||
countOffset,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
20
awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js
Normal file
20
awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
border-radius: 1em;
|
||||
background-color: var(--pf-global--BackgroundColor--light-200);
|
||||
font-size: 0.6rem;
|
||||
width: max-content;
|
||||
padding: 0em 1em;
|
||||
margin-left: auto;
|
||||
margin-right: -0.3em;
|
||||
`;
|
||||
|
||||
export default function JobEventEllipsis({ isCollapsed }) {
|
||||
if (!isCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Wrapper>...</Wrapper>;
|
||||
}
|
||||
@ -8,10 +8,6 @@ export default styled.div`
|
||||
cursor: ${(props) => (props.isClickable ? 'pointer' : 'default')};
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import { AngleDownIcon, AngleRightIcon } from '@patternfly/react-icons';
|
||||
|
||||
export default styled.div`
|
||||
const Wrapper = styled.div`
|
||||
background-color: #ebebeb;
|
||||
color: #646972;
|
||||
display: flex;
|
||||
@ -8,10 +11,34 @@ export default styled.div`
|
||||
font-size: 18px;
|
||||
justify-content: center;
|
||||
line-height: 12px;
|
||||
|
||||
& > i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
export default function JobEventLineToggle({
|
||||
canToggle,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}) {
|
||||
if (!canToggle) {
|
||||
return <Wrapper />;
|
||||
}
|
||||
return (
|
||||
<Wrapper>
|
||||
<Button onClick={onToggle} type="button">
|
||||
{isCollapsed ? (
|
||||
<AngleRightIcon size="sm" title={t`Expand section`} />
|
||||
) : (
|
||||
<AngleDownIcon size="sm" title={t`Collapse section`} />
|
||||
)}
|
||||
</Button>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ export { default as JobEventLineToggle } from './JobEventLineToggle';
|
||||
export { default as JobEventLineNumber } from './JobEventLineNumber';
|
||||
export { default as JobEventLineText } from './JobEventLineText';
|
||||
export { default as OutputToolbar } from './OutputToolbar';
|
||||
export { default as JobEventEllipsis } from './JobEventEllipsis';
|
||||
|
||||
498
awx/ui/src/screens/Job/JobOutput/useJobEvents.js
Normal file
498
awx/ui/src/screens/Job/JobOutput/useJobEvents.js
Normal file
@ -0,0 +1,498 @@
|
||||
import { useState, useEffect, useReducer } from 'react';
|
||||
|
||||
const initialState = {
|
||||
// array of root level nodes (no parent_uuid)
|
||||
tree: [],
|
||||
// all events indexed by counter value
|
||||
events: {},
|
||||
// counter value indexed by uuid
|
||||
uuidMap: {},
|
||||
// events with parent events that aren't yet loaded.
|
||||
// arrays indexed by parent uuid
|
||||
eventsWithoutParents: {},
|
||||
};
|
||||
export const ADD_EVENTS = 'ADD_EVENTS';
|
||||
export const TOGGLE_NODE_COLLAPSED = 'TOGGLE_NODE_COLLAPSED';
|
||||
export const SET_EVENT_NUM_CHILDREN = 'SET_EVENT_NUM_CHILDREN';
|
||||
export const CLEAR_EVENTS = 'CLEAR_EVENTS';
|
||||
export const REBUILD_TREE = 'REBUILD_TREE';
|
||||
|
||||
export default function useJobEvents(callbacks, isFlatMode) {
|
||||
const [actionQueue, setActionQueue] = useState([]);
|
||||
const enqueueAction = (action) => {
|
||||
setActionQueue((queue) => queue.concat(action));
|
||||
};
|
||||
const reducer = jobEventsReducer(callbacks, isFlatMode, enqueueAction);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
setActionQueue((queue) => {
|
||||
const action = queue[0];
|
||||
if (!action) {
|
||||
return queue;
|
||||
}
|
||||
try {
|
||||
dispatch(action);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
return queue.slice(1);
|
||||
});
|
||||
}, [actionQueue]);
|
||||
|
||||
return {
|
||||
addEvents: (events) => dispatch({ type: ADD_EVENTS, events }),
|
||||
getNodeByUuid: (uuid) => getNodeByUuid(state, uuid),
|
||||
toggleNodeIsCollapsed: (uuid) =>
|
||||
dispatch({ type: TOGGLE_NODE_COLLAPSED, uuid }),
|
||||
getEventForRow: (rowIndex) => getEventForRow(state, rowIndex),
|
||||
getNodeForRow: (rowIndex) => getNodeForRow(state, rowIndex),
|
||||
getTotalNumChildren: (uuid) => {
|
||||
const node = getNodeByUuid(state, uuid);
|
||||
return getTotalNumChildren(node);
|
||||
},
|
||||
getNumCollapsedEvents: () =>
|
||||
state.tree.reduce((sum, node) => sum + getNumCollapsedChildren(node), 0),
|
||||
getCounterForRow: (rowIndex) => getCounterForRow(state, rowIndex),
|
||||
getEvent: (eventIndex) => getEvent(state, eventIndex),
|
||||
clearLoadedEvents: () => dispatch({ type: CLEAR_EVENTS }),
|
||||
rebuildEventsTree: () => dispatch({ type: REBUILD_TREE }),
|
||||
};
|
||||
}
|
||||
|
||||
export function jobEventsReducer(callbacks, isFlatMode, enqueueAction) {
|
||||
return (state, action) => {
|
||||
switch (action.type) {
|
||||
case ADD_EVENTS:
|
||||
return addEvents(state, action.events);
|
||||
case TOGGLE_NODE_COLLAPSED:
|
||||
return toggleNodeIsCollapsed(state, action.uuid);
|
||||
case SET_EVENT_NUM_CHILDREN:
|
||||
return setEventNumChildren(state, action.uuid, action.numChildren);
|
||||
case CLEAR_EVENTS:
|
||||
return initialState;
|
||||
case REBUILD_TREE:
|
||||
return rebuildTree(state);
|
||||
default:
|
||||
throw new Error(`Unrecognized action: ${action.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
function addEvents(origState, newEvents) {
|
||||
let state = {
|
||||
...origState,
|
||||
events: { ...origState.events },
|
||||
tree: [...origState.tree],
|
||||
};
|
||||
const parentsToFetch = {};
|
||||
newEvents.forEach((event) => {
|
||||
if (
|
||||
typeof event.rowNumber !== 'number' ||
|
||||
Number.isNaN(event.rowNumber)
|
||||
) {
|
||||
throw new Error('Cannot add event; missing rowNumber');
|
||||
}
|
||||
const eventIndex = event.counter;
|
||||
if (state.events[eventIndex]) {
|
||||
state.events[eventIndex] = event;
|
||||
state = _gatherEventsForNewParent(state, event.uuid);
|
||||
return;
|
||||
}
|
||||
if (!event.parent_uuid || isFlatMode) {
|
||||
state = _addRootLevelEvent(state, event);
|
||||
return;
|
||||
}
|
||||
|
||||
let isParentFound;
|
||||
[state, isParentFound] = _addNestedLevelEvent(state, event);
|
||||
if (!isParentFound) {
|
||||
parentsToFetch[event.parent_uuid] = {
|
||||
childCounter: event.counter,
|
||||
childRowNumber: event.rowNumber,
|
||||
};
|
||||
state = _addEventWithoutParent(state, event);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(parentsToFetch).forEach(async (uuid) => {
|
||||
const { childCounter, childRowNumber } = parentsToFetch[uuid];
|
||||
const parent = await callbacks.fetchEventByUuid(uuid);
|
||||
const numPrevSiblings = await callbacks.fetchNumEvents(
|
||||
parent.counter,
|
||||
childCounter
|
||||
);
|
||||
parent.rowNumber = childRowNumber - numPrevSiblings - 1;
|
||||
enqueueAction({
|
||||
type: ADD_EVENTS,
|
||||
events: [parent],
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function _addRootLevelEvent(state, event) {
|
||||
const eventIndex = event.counter;
|
||||
const newNode = {
|
||||
eventIndex,
|
||||
isCollapsed: false,
|
||||
children: [],
|
||||
};
|
||||
const index = state.tree.findIndex((node) => node.eventIndex > eventIndex);
|
||||
const updatedTree = [...state.tree];
|
||||
if (index === -1) {
|
||||
updatedTree.push(newNode);
|
||||
} else {
|
||||
updatedTree.splice(index, 0, newNode);
|
||||
}
|
||||
return _gatherEventsForNewParent(
|
||||
{
|
||||
...state,
|
||||
events: { ...state.events, [eventIndex]: event },
|
||||
tree: updatedTree,
|
||||
uuidMap: {
|
||||
...state.uuidMap,
|
||||
[event.uuid]: eventIndex,
|
||||
},
|
||||
},
|
||||
event.uuid
|
||||
);
|
||||
}
|
||||
|
||||
function _addNestedLevelEvent(state, event) {
|
||||
const eventIndex = event.counter;
|
||||
const parent = getNodeByUuid(state, event.parent_uuid);
|
||||
if (!parent) {
|
||||
return [state, false];
|
||||
}
|
||||
const newNode = {
|
||||
eventIndex,
|
||||
isCollapsed: false,
|
||||
children: [],
|
||||
};
|
||||
const index = parent.children.findIndex(
|
||||
(node) => node.eventIndex >= eventIndex
|
||||
);
|
||||
const length = parent.children.length + 1;
|
||||
if (index === -1) {
|
||||
state = updateNodeByUuid(state, event.parent_uuid, (node) => {
|
||||
node.children.push(newNode);
|
||||
return node;
|
||||
});
|
||||
} else {
|
||||
state = updateNodeByUuid(state, event.parent_uuid, (node) => {
|
||||
node.children.splice(index, 0, newNode);
|
||||
return node;
|
||||
});
|
||||
}
|
||||
state = _gatherEventsForNewParent(
|
||||
{
|
||||
...state,
|
||||
events: {
|
||||
...state.events,
|
||||
[eventIndex]: event,
|
||||
},
|
||||
uuidMap: {
|
||||
...state.uuidMap,
|
||||
[event.uuid]: eventIndex,
|
||||
},
|
||||
},
|
||||
event.uuid
|
||||
);
|
||||
if (length === 1) {
|
||||
_fetchNumChildren(state, parent);
|
||||
}
|
||||
|
||||
return [state, true];
|
||||
}
|
||||
|
||||
function _addEventWithoutParent(state, event) {
|
||||
const parentUuid = event.parent_uuid;
|
||||
let eventsList;
|
||||
if (!state.eventsWithoutParents[parentUuid]) {
|
||||
eventsList = [event];
|
||||
} else {
|
||||
eventsList = state.eventsWithoutParents[parentUuid].concat(event);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
eventsWithoutParents: {
|
||||
...state.eventsWithoutParents,
|
||||
[parentUuid]: eventsList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function _fetchNumChildren(state, node) {
|
||||
const event = state.events[node.eventIndex];
|
||||
if (!event) {
|
||||
throw new Error(
|
||||
`Cannot fetch numChildren; event ${node.eventIndex} not found`
|
||||
);
|
||||
}
|
||||
const sibling = await _getNextSibling(state, event);
|
||||
const numChildren = await callbacks.fetchNumEvents(
|
||||
event.counter,
|
||||
sibling?.counter
|
||||
);
|
||||
enqueueAction({
|
||||
type: SET_EVENT_NUM_CHILDREN,
|
||||
uuid: event.uuid,
|
||||
numChildren,
|
||||
});
|
||||
if (sibling) {
|
||||
sibling.rowNumber = event.rowNumber + numChildren + 1;
|
||||
enqueueAction({
|
||||
type: ADD_EVENTS,
|
||||
events: [sibling],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function _getNextSibling(state, event) {
|
||||
if (!event.parent_uuid) {
|
||||
return callbacks.fetchNextRootNode(event.counter);
|
||||
}
|
||||
const parentNode = getNodeByUuid(state, event.parent_uuid);
|
||||
const parent = state.events[parentNode.eventIndex];
|
||||
const sibling = await callbacks.fetchNextSibling(parent.id, event.counter);
|
||||
if (!sibling) {
|
||||
return _getNextSibling(state, parent);
|
||||
}
|
||||
return sibling;
|
||||
}
|
||||
|
||||
function _gatherEventsForNewParent(state, parentUuid) {
|
||||
if (!state.eventsWithoutParents[parentUuid]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { [parentUuid]: newEvents, ...remaining } =
|
||||
state.eventsWithoutParents;
|
||||
return addEvents(
|
||||
{
|
||||
...state,
|
||||
eventsWithoutParents: remaining,
|
||||
},
|
||||
newEvents
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildTree(state) {
|
||||
const events = Object.values(state.events);
|
||||
return addEvents(initialState, events);
|
||||
}
|
||||
}
|
||||
|
||||
function getEventForRow(state, rowIndex) {
|
||||
const { node } = _getNodeForRow(state, rowIndex, state.tree);
|
||||
if (node) {
|
||||
return {
|
||||
node,
|
||||
event: state.events[node.eventIndex],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNodeForRow(state, rowToFind) {
|
||||
const { node } = _getNodeForRow(state, rowToFind, state.tree);
|
||||
return node;
|
||||
}
|
||||
|
||||
function getCounterForRow(state, rowToFind) {
|
||||
const { node, expectedCounter } = _getNodeForRow(
|
||||
state,
|
||||
rowToFind,
|
||||
state.tree
|
||||
);
|
||||
|
||||
if (node) {
|
||||
const event = state.events[node.eventIndex];
|
||||
return event.counter;
|
||||
}
|
||||
return expectedCounter;
|
||||
}
|
||||
|
||||
function _getNodeForRow(state, rowToFind, nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const event = state.events[node.eventIndex];
|
||||
if (event.rowNumber === rowToFind) {
|
||||
return { node };
|
||||
}
|
||||
const totalNodeDescendants = getTotalNumChildren(node);
|
||||
const numCollapsedChildren = getNumCollapsedChildren(node);
|
||||
const nodeChildren = totalNodeDescendants - numCollapsedChildren;
|
||||
if (event.rowNumber + nodeChildren >= rowToFind) {
|
||||
// requested row is in children/descendants
|
||||
return _getNodeInChildren(state, node, rowToFind);
|
||||
}
|
||||
rowToFind += numCollapsedChildren;
|
||||
|
||||
const nextNode = nodes[i + 1];
|
||||
if (!nextNode) {
|
||||
continue;
|
||||
}
|
||||
const nextEvent = state.events[nextNode.eventIndex];
|
||||
const lastChild = _getLastDescendantNode([node]);
|
||||
if (nextEvent.rowNumber > rowToFind) {
|
||||
// requested row is not loaded; return best guess at counter number
|
||||
const lastChildEvent = state.events[lastChild.eventIndex];
|
||||
const rowDiff = rowToFind - lastChildEvent.rowNumber;
|
||||
return {
|
||||
node: null,
|
||||
expectedCounter: lastChild.eventIndex + rowDiff,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const lastDescendant = _getLastDescendantNode(nodes);
|
||||
if (!lastDescendant) {
|
||||
return { node: null, expectedCounter: rowToFind };
|
||||
}
|
||||
|
||||
const lastDescendantEvent = state.events[lastDescendant.eventIndex];
|
||||
const rowDiff = rowToFind - lastDescendantEvent.rowNumber;
|
||||
return {
|
||||
node: null,
|
||||
expectedCounter: lastDescendant.eventIndex + rowDiff,
|
||||
};
|
||||
}
|
||||
|
||||
function _getNodeInChildren(state, node, rowToFind) {
|
||||
const event = state.events[node.eventIndex];
|
||||
const firstChild = state.events[node.children[0].eventIndex];
|
||||
if (rowToFind < firstChild.rowNumber) {
|
||||
const rowDiff = rowToFind - event.rowNumber;
|
||||
return {
|
||||
node: null,
|
||||
expectedCounter: event.counter + rowDiff,
|
||||
};
|
||||
}
|
||||
return _getNodeForRow(state, rowToFind, node.children);
|
||||
}
|
||||
|
||||
function _getLastDescendantNode(nodes) {
|
||||
let lastDescendant = nodes[nodes.length - 1];
|
||||
let children = lastDescendant?.children || [];
|
||||
while (children.length) {
|
||||
lastDescendant = children[children.length - 1];
|
||||
children = lastDescendant.children;
|
||||
}
|
||||
return lastDescendant;
|
||||
}
|
||||
|
||||
function getTotalNumChildren(node) {
|
||||
if (typeof node.numChildren !== 'undefined') {
|
||||
return node.numChildren;
|
||||
}
|
||||
|
||||
let estimatedNumChildren = node.children.length;
|
||||
node.children.forEach((child) => {
|
||||
estimatedNumChildren += getTotalNumChildren(child);
|
||||
});
|
||||
return estimatedNumChildren;
|
||||
}
|
||||
|
||||
function getNumCollapsedChildren(node) {
|
||||
if (node.isCollapsed) {
|
||||
return getTotalNumChildren(node);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
node.children.forEach((child) => {
|
||||
sum += getNumCollapsedChildren(child);
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
|
||||
function toggleNodeIsCollapsed(state, eventUuid) {
|
||||
return updateNodeByUuid(state, eventUuid, (node) => ({
|
||||
...node,
|
||||
isCollapsed: !node.isCollapsed,
|
||||
}));
|
||||
}
|
||||
|
||||
function updateNodeByUuid(state, uuid, update) {
|
||||
if (!state.uuidMap[uuid]) {
|
||||
throw new Error(`Cannot update node; Event UUID not found ${uuid}`);
|
||||
}
|
||||
const index = state.uuidMap[uuid];
|
||||
return {
|
||||
...state,
|
||||
tree: _updateNodeByIndex(index, state.tree, update),
|
||||
};
|
||||
}
|
||||
|
||||
function _updateNodeByIndex(target, nodeArray, update) {
|
||||
const nextIndex = nodeArray.findIndex((node) => node.eventIndex > target);
|
||||
const targetIndex = nextIndex === -1 ? nodeArray.length - 1 : nextIndex - 1;
|
||||
let updatedNode;
|
||||
if (nodeArray[targetIndex].eventIndex === target) {
|
||||
updatedNode = update({
|
||||
...nodeArray[targetIndex],
|
||||
children: [...nodeArray[targetIndex].children],
|
||||
});
|
||||
} else {
|
||||
updatedNode = {
|
||||
...nodeArray[targetIndex],
|
||||
children: _updateNodeByIndex(
|
||||
target,
|
||||
nodeArray[targetIndex].children,
|
||||
update
|
||||
),
|
||||
};
|
||||
}
|
||||
return [
|
||||
...nodeArray.slice(0, targetIndex),
|
||||
updatedNode,
|
||||
...nodeArray.slice(targetIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
function getNodeByUuid(state, uuid) {
|
||||
if (!state.uuidMap[uuid]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = state.uuidMap[uuid];
|
||||
return _getNodeByIndex(state.tree, index);
|
||||
}
|
||||
|
||||
function _getNodeByIndex(arr, index) {
|
||||
if (!arr.length) {
|
||||
return null;
|
||||
}
|
||||
const i = arr.findIndex((node) => node.eventIndex >= index);
|
||||
if (i === -1) {
|
||||
return _getNodeByIndex(arr[arr.length - 1].children, index);
|
||||
}
|
||||
if (arr[i].eventIndex === index) {
|
||||
return arr[i];
|
||||
}
|
||||
if (!arr[i - 1]) {
|
||||
return null;
|
||||
}
|
||||
return _getNodeByIndex(arr[i - 1].children, index);
|
||||
}
|
||||
|
||||
function setEventNumChildren(state, uuid, numChildren) {
|
||||
if (!state.uuidMap[uuid]) {
|
||||
return state;
|
||||
}
|
||||
return updateNodeByUuid(state, uuid, (node) => ({
|
||||
...node,
|
||||
numChildren,
|
||||
}));
|
||||
}
|
||||
|
||||
function getEvent(state, eventIndex) {
|
||||
const event = state.events[eventIndex];
|
||||
if (event) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
1643
awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js
Normal file
1643
awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user