mirror of
https://github.com/ansible/awx.git
synced 2026-04-13 22:19:27 -02:30
Job Output expand/collapse take 2 (#11312)
This commit is contained in:
@@ -18,6 +18,7 @@ import InventorySources from './models/InventorySources';
|
|||||||
import InventoryUpdates from './models/InventoryUpdates';
|
import InventoryUpdates from './models/InventoryUpdates';
|
||||||
import JobTemplates from './models/JobTemplates';
|
import JobTemplates from './models/JobTemplates';
|
||||||
import Jobs from './models/Jobs';
|
import Jobs from './models/Jobs';
|
||||||
|
import JobEvents from './models/JobEvents';
|
||||||
import Labels from './models/Labels';
|
import Labels from './models/Labels';
|
||||||
import Me from './models/Me';
|
import Me from './models/Me';
|
||||||
import Metrics from './models/Metrics';
|
import Metrics from './models/Metrics';
|
||||||
@@ -63,6 +64,7 @@ const InventorySourcesAPI = new InventorySources();
|
|||||||
const InventoryUpdatesAPI = new InventoryUpdates();
|
const InventoryUpdatesAPI = new InventoryUpdates();
|
||||||
const JobTemplatesAPI = new JobTemplates();
|
const JobTemplatesAPI = new JobTemplates();
|
||||||
const JobsAPI = new Jobs();
|
const JobsAPI = new Jobs();
|
||||||
|
const JobEventsAPI = new JobEvents();
|
||||||
const LabelsAPI = new Labels();
|
const LabelsAPI = new Labels();
|
||||||
const MeAPI = new Me();
|
const MeAPI = new Me();
|
||||||
const MetricsAPI = new Metrics();
|
const MetricsAPI = new Metrics();
|
||||||
@@ -109,6 +111,7 @@ export {
|
|||||||
InventoryUpdatesAPI,
|
InventoryUpdatesAPI,
|
||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
JobsAPI,
|
JobsAPI,
|
||||||
|
JobEventsAPI,
|
||||||
LabelsAPI,
|
LabelsAPI,
|
||||||
MeAPI,
|
MeAPI,
|
||||||
MetricsAPI,
|
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 {
|
import {
|
||||||
JobEventLine,
|
JobEventLine,
|
||||||
JobEventLineToggle,
|
JobEventLineToggle,
|
||||||
JobEventLineNumber,
|
JobEventLineNumber,
|
||||||
JobEventLineText,
|
JobEventLineText,
|
||||||
|
JobEventEllipsis,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
function JobEvent({
|
function JobEvent({
|
||||||
counter,
|
|
||||||
stdout,
|
|
||||||
style,
|
style,
|
||||||
type,
|
|
||||||
lineTextHtml,
|
lineTextHtml,
|
||||||
isClickable,
|
isClickable,
|
||||||
onJobEventClick,
|
onJobEventClick,
|
||||||
|
event,
|
||||||
|
measure,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapsed,
|
||||||
|
hasChildren,
|
||||||
}) {
|
}) {
|
||||||
return !stdout ? null : (
|
const numOutputLines = lineTextHtml?.length || 0;
|
||||||
<div style={style} type={type}>
|
useEffect(() => {
|
||||||
{lineTextHtml.map(
|
measure();
|
||||||
({ lineNumber, html }) =>
|
}, [numOutputLines, isCollapsed, measure]);
|
||||||
lineNumber >= 0 && (
|
|
||||||
<JobEventLine
|
let toggleLineIndex = -1;
|
||||||
onClick={isClickable ? onJobEventClick : undefined}
|
if (hasChildren) {
|
||||||
key={`${counter}-${lineNumber}`}
|
lineTextHtml.forEach(({ html }, index) => {
|
||||||
isFirst={lineNumber === 0}
|
if (html) {
|
||||||
isClickable={isClickable}
|
toggleLineIndex = index;
|
||||||
>
|
}
|
||||||
<JobEventLineToggle />
|
});
|
||||||
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
|
}
|
||||||
<JobEventLineText
|
return !event.stdout ? null : (
|
||||||
type="job_event_line_text"
|
<div style={style} type={event.type}>
|
||||||
dangerouslySetInnerHTML={{
|
{lineTextHtml.map(({ lineNumber, html }, index) => {
|
||||||
__html: html,
|
if (lineNumber < 0) {
|
||||||
}}
|
return null;
|
||||||
/>
|
}
|
||||||
</JobEventLine>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { shallow } from 'enzyme';
|
||||||
import JobEvent from './JobEvent';
|
import JobEvent from './JobEvent';
|
||||||
|
|
||||||
const mockOnPlayStartEvent = {
|
const mockOnPlayStartEvent = {
|
||||||
@@ -19,9 +19,6 @@ const mockRunnerOnOkEvent = {
|
|||||||
end_line: 5,
|
end_line: 5,
|
||||||
stdout: '\u001b[0;32mok: [localhost]\u001b[0m',
|
stdout: '\u001b[0;32mok: [localhost]\u001b[0m',
|
||||||
};
|
};
|
||||||
const selectors = {
|
|
||||||
lineText: 'JobEventLineText',
|
|
||||||
};
|
|
||||||
|
|
||||||
const singleDigitTimestampEvent = {
|
const singleDigitTimestampEvent = {
|
||||||
...mockOnPlayStartEvent,
|
...mockOnPlayStartEvent,
|
||||||
@@ -52,55 +49,51 @@ const mockOnPlayStartLineTextHtml = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('<JobEvent />', () => {
|
describe('<JobEvent />', () => {
|
||||||
test('initially renders successfully', () => {
|
|
||||||
mountWithContexts(
|
|
||||||
<JobEvent
|
|
||||||
lineTextHtml={mockOnPlayStartLineTextHtml}
|
|
||||||
{...mockOnPlayStartEvent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('playbook event timestamps are rendered', () => {
|
test('playbook event timestamps are rendered', () => {
|
||||||
let wrapper = mountWithContexts(
|
const wrapper1 = shallow(
|
||||||
<JobEvent
|
<JobEvent
|
||||||
lineTextHtml={mockOnPlayStartLineTextHtml}
|
lineTextHtml={mockOnPlayStartLineTextHtml}
|
||||||
{...mockOnPlayStartEvent}
|
event={mockOnPlayStartEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
let lineText = wrapper.find(selectors.lineText);
|
const lineText1 = wrapper1.find('JobEventLineText');
|
||||||
expect(
|
const html1 = lineText1.at(1).prop('dangerouslySetInnerHTML').__html;
|
||||||
lineText.filterWhere((e) => e.text().includes('18:11:22'))
|
expect(html1.includes('18:11:22')).toBe(true);
|
||||||
).toHaveLength(1);
|
|
||||||
|
|
||||||
wrapper = mountWithContexts(
|
const wrapper2 = shallow(
|
||||||
<JobEvent
|
<JobEvent
|
||||||
lineTextHtml={mockSingleDigitTimestampEventLineTextHtml}
|
lineTextHtml={mockSingleDigitTimestampEventLineTextHtml}
|
||||||
{...singleDigitTimestampEvent}
|
event={singleDigitTimestampEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
lineText = wrapper.find(selectors.lineText);
|
const lineText2 = wrapper2.find('JobEventLineText');
|
||||||
expect(
|
const html2 = lineText2.at(1).prop('dangerouslySetInnerHTML').__html;
|
||||||
lineText.filterWhere((e) => e.text().includes('08:01:02'))
|
expect(html2.includes('08:01:02')).toBe(true);
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ansi stdout colors are rendered as html', () => {
|
test('ansi stdout colors are rendered as html', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = shallow(
|
||||||
<JobEvent lineTextHtml={mockAnsiLineTextHtml} {...mockRunnerOnOkEvent} />
|
<JobEvent
|
||||||
|
lineTextHtml={mockAnsiLineTextHtml}
|
||||||
|
event={mockRunnerOnOkEvent}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
const lineText = wrapper.find(selectors.lineText);
|
const lineText = wrapper.find('JobEventLineText');
|
||||||
expect(
|
expect(
|
||||||
lineText
|
lineText
|
||||||
.html()
|
.prop('dangerouslySetInnerHTML')
|
||||||
.includes('<span class="output--1977390340">ok: [localhost]</span>')
|
.__html.includes(
|
||||||
|
'<span class="output--1977390340">ok: [localhost]</span>'
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("events without stdout aren't rendered", () => {
|
test("events without stdout aren't rendered", () => {
|
||||||
const missingStdoutEvent = { ...mockOnPlayStartEvent };
|
const missingStdoutEvent = { ...mockOnPlayStartEvent };
|
||||||
delete missingStdoutEvent.stdout;
|
delete missingStdoutEvent.stdout;
|
||||||
const wrapper = mountWithContexts(<JobEvent {...missingStdoutEvent} />);
|
const wrapper = shallow(
|
||||||
expect(wrapper.find(selectors.lineText)).toHaveLength(0);
|
<JobEvent lineTextHtml={[]} event={missingStdoutEvent} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find('JobEventLineText')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
JobEventLine,
|
JobEventLine,
|
||||||
JobEventLineToggle,
|
JobEventLineToggle,
|
||||||
@@ -14,7 +14,11 @@ function JobEventSkeletonContent({ contentLength }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobEventSkeleton({ counter, contentLength, style }) {
|
function JobEventSkeleton({ counter, contentLength, style, measure }) {
|
||||||
|
useEffect(() => {
|
||||||
|
measure();
|
||||||
|
}, [measure]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
counter > 1 && (
|
counter > 1 && (
|
||||||
<div style={style}>
|
<div style={style}>
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import JobEventSkeleton from './JobEventSkeleton';
|
|||||||
|
|
||||||
const contentSelector = 'JobEventSkeletonContent';
|
const contentSelector = 'JobEventSkeletonContent';
|
||||||
|
|
||||||
describe('<JobEvenSkeletont />', () => {
|
describe('<JobEvenSkeleton />', () => {
|
||||||
test('initially renders successfully', () => {
|
test('initially renders successfully', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<JobEventSkeleton contentLength={80} counter={100} />
|
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={100} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find(contentSelector).length).toEqual(1);
|
expect(wrapper.find(contentSelector).length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('always skips first counter', () => {
|
test('always skips first counter', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<JobEventSkeleton contentLength={80} counter={1} />
|
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={1} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find(contentSelector).length).toEqual(0);
|
expect(wrapper.find(contentSelector).length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ContentError from 'components/ContentError';
|
|||||||
import ContentLoading from 'components/ContentLoading';
|
import ContentLoading from 'components/ContentLoading';
|
||||||
import ErrorDetail from 'components/ErrorDetail';
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
import StatusIcon from 'components/StatusIcon';
|
import StatusIcon from 'components/StatusIcon';
|
||||||
|
import { JobEventsAPI } from 'api';
|
||||||
|
|
||||||
import { getJobModel, isJobRunning } from 'util/jobs';
|
import { getJobModel, isJobRunning } from 'util/jobs';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
@@ -33,7 +34,8 @@ import getLineTextHtml from './getLineTextHtml';
|
|||||||
import connectJobSocket, { closeWebSocket } from './connectJobSocket';
|
import connectJobSocket, { closeWebSocket } from './connectJobSocket';
|
||||||
import getEventRequestParams from './getEventRequestParams';
|
import getEventRequestParams from './getEventRequestParams';
|
||||||
import isHostEvent from './isHostEvent';
|
import isHostEvent from './isHostEvent';
|
||||||
import { fetchCount, normalizeEvents } from './loadJobEvents';
|
import { prependTraceback } from './loadJobEvents';
|
||||||
|
import useJobEvents from './useJobEvents';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('job_output', {
|
const QS_CONFIG = getQSConfig('job_output', {
|
||||||
order_by: 'counter',
|
order_by: 'counter',
|
||||||
@@ -94,20 +96,113 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
const scrollTop = useRef(0);
|
const scrollTop = useRef(0);
|
||||||
const scrollHeight = useRef(0);
|
const scrollHeight = useRef(0);
|
||||||
const history = useHistory();
|
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 [cssMap, setCssMap] = useState({});
|
||||||
|
const [remoteRowCount, setRemoteRowCount] = useState(0);
|
||||||
|
const [contentError, setContentError] = useState(null);
|
||||||
const [currentlyLoading, setCurrentlyLoading] = useState([]);
|
const [currentlyLoading, setCurrentlyLoading] = useState([]);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
const [hostEvent, setHostEvent] = useState({});
|
const [hostEvent, setHostEvent] = useState({});
|
||||||
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
|
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
|
||||||
const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting');
|
|
||||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
const [remoteRowCount, setRemoteRowCount] = useState(0);
|
const [highestLoadedCounter, setHighestLoadedCounter] = useState(0);
|
||||||
const [results, setResults] = useState({});
|
|
||||||
const [isFollowModeEnabled, setIsFollowModeEnabled] = useState(
|
const [isFollowModeEnabled, setIsFollowModeEnabled] = useState(
|
||||||
isJobRunning(job.status)
|
isJobRunning(job.status)
|
||||||
);
|
);
|
||||||
const [isMonitoringWebsocket, setIsMonitoringWebsocket] = useState(false);
|
const [isMonitoringWebsocket, setIsMonitoringWebsocket] = useState(false);
|
||||||
|
const [lastScrollPosition, setLastScrollPosition] = useState(0);
|
||||||
|
|
||||||
|
const totalNonCollapsedRows = Math.max(
|
||||||
|
remoteRowCount - getNumCollapsedEvents(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
useInterval(
|
useInterval(
|
||||||
() => {
|
() => {
|
||||||
@@ -117,53 +212,116 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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)) {
|
useEffect(() => {
|
||||||
connectJobSocket(job, (data) => {
|
if (!isJobRunning(jobStatus)) {
|
||||||
if (data.group_name === 'job_events') {
|
setIsFollowModeEnabled(false);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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() {
|
return function cleanup() {
|
||||||
|
clearTimeout(batchTimeout);
|
||||||
closeWebSocket();
|
closeWebSocket();
|
||||||
setIsMonitoringWebsocket(false);
|
setIsMonitoringWebsocket(false);
|
||||||
isMounted.current = false;
|
isMounted.current = false;
|
||||||
};
|
};
|
||||||
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isJobRunning(jobStatus)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listRef.current?.recomputeRowHeights) {
|
if (listRef.current?.recomputeRowHeights) {
|
||||||
listRef.current.recomputeRowHeights();
|
listRef.current.recomputeRowHeights();
|
||||||
}
|
}
|
||||||
}, [currentlyLoading, cssMap, remoteRowCount]);
|
}, [currentlyLoading, cssMap, remoteRowCount, wsEvents.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jobStatus && !isJobRunning(jobStatus)) {
|
if (!jobStatus || isJobRunning(jobStatus)) {
|
||||||
if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
|
return;
|
||||||
setRemoteRowCount(jobSocketCounter.current);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isMonitoringWebsocket) {
|
if (isMonitoringWebsocket) {
|
||||||
setIsMonitoringWebsocket(false);
|
setIsMonitoringWebsocket(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFollowModeEnabled) {
|
if (isFollowModeEnabled) {
|
||||||
setTimeout(() => setIsFollowModeEnabled(false), 1000);
|
setTimeout(() => setIsFollowModeEnabled(false), 1000);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [jobStatus]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [jobStatus]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -197,9 +355,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
useDismissableError(deleteError);
|
useDismissableError(deleteError);
|
||||||
|
|
||||||
const monitorJobSocketCounter = () => {
|
const monitorJobSocketCounter = () => {
|
||||||
if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
|
|
||||||
setRemoteRowCount(jobSocketCounter.current);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
jobSocketCounter.current === remoteRowCount &&
|
jobSocketCounter.current === remoteRowCount &&
|
||||||
!isJobRunning(job.status)
|
!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, {
|
const eventPromise = getJobModel(job.type).readEvents(job.id, {
|
||||||
...params,
|
...params,
|
||||||
...parseQueryString(QS_CONFIG, location.search),
|
...qsParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [
|
const {
|
||||||
{
|
data: { count, results: fetchedEvents = [] },
|
||||||
data: { results: fetchedEvents = [] },
|
} = await eventPromise;
|
||||||
},
|
|
||||||
count,
|
|
||||||
] = await Promise.all([eventPromise, fetchCount(job, eventPromise)]);
|
|
||||||
|
|
||||||
if (!isMounted.current) {
|
if (!isMounted.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { events, countOffset } = normalizeEvents(job, fetchedEvents);
|
let newCssMap;
|
||||||
|
let rowNumber = 0;
|
||||||
const newResults = {};
|
const { events, countOffset } = prependTraceback(job, fetchedEvents);
|
||||||
let newResultsCssMap = {};
|
events.forEach((event) => {
|
||||||
events.forEach((jobEvent, index) => {
|
event.rowNumber = rowNumber;
|
||||||
newResults[index] = jobEvent;
|
rowNumber++;
|
||||||
const { lineCssMap } = getLineTextHtml(jobEvent);
|
const { lineCssMap } = getLineTextHtml(event);
|
||||||
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
|
newCssMap = {
|
||||||
|
...newCssMap,
|
||||||
|
...lineCssMap,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setResults(newResults);
|
setCssMap((prevCssMap) => ({
|
||||||
|
...prevCssMap,
|
||||||
|
...newCssMap,
|
||||||
|
}));
|
||||||
|
const lastCounter = events[events.length - 1]?.counter || 50;
|
||||||
|
addEvents(events);
|
||||||
|
setHighestLoadedCounter(lastCounter);
|
||||||
setRemoteRowCount(count + countOffset);
|
setRemoteRowCount(count + countOffset);
|
||||||
setCssMap(newResultsCssMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setContentError(err);
|
setContentError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -262,10 +426,20 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isRowLoaded = ({ index }) => {
|
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 true;
|
||||||
}
|
}
|
||||||
return currentlyLoading.includes(index);
|
if (index > remoteRowCount && index < remoteRowCount + wsEvents.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return currentlyLoading.includes(counter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHostEventClick = (hostEventToOpen) => {
|
const handleHostEventClick = (hostEventToOpen) => {
|
||||||
@@ -281,9 +455,30 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
if (listRef.current && isFollowModeEnabled) {
|
if (listRef.current && isFollowModeEnabled) {
|
||||||
setTimeout(() => scrollToRow(remoteRowCount - 1), 0);
|
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 = [];
|
let actualLineTextHtml = [];
|
||||||
if (results[index]) {
|
if (event) {
|
||||||
const { lineTextHtml } = getLineTextHtml(results[index]);
|
const { lineTextHtml } = getLineTextHtml(event);
|
||||||
actualLineTextHtml = lineTextHtml;
|
actualLineTextHtml = lineTextHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,86 +490,120 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
columnIndex={0}
|
columnIndex={0}
|
||||||
>
|
>
|
||||||
{results[index] ? (
|
{({ measure }) =>
|
||||||
<JobEvent
|
event ? (
|
||||||
isClickable={isHostEvent(results[index])}
|
<JobEvent
|
||||||
onJobEventClick={() => handleHostEventClick(results[index])}
|
isClickable={isHostEvent(event)}
|
||||||
className="row"
|
onJobEventClick={() => handleHostEventClick(event)}
|
||||||
style={style}
|
className="row"
|
||||||
lineTextHtml={actualLineTextHtml}
|
style={style}
|
||||||
index={index}
|
lineTextHtml={actualLineTextHtml}
|
||||||
{...results[index]}
|
index={index}
|
||||||
/>
|
event={event}
|
||||||
) : (
|
measure={measure}
|
||||||
<JobEventSkeleton
|
isCollapsed={node.isCollapsed}
|
||||||
className="row"
|
hasChildren={node.children.length}
|
||||||
style={style}
|
onToggleCollapsed={() => {
|
||||||
counter={index}
|
toggleNodeIsCollapsed(event.uuid);
|
||||||
contentLength={80}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
<JobEventSkeleton
|
||||||
|
className="row"
|
||||||
|
style={style}
|
||||||
|
counter={index}
|
||||||
|
contentLength={80}
|
||||||
|
measure={measure}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreRows = ({ startIndex, stopIndex }) => {
|
const loadMoreRows = async ({ startIndex, stopIndex }) => {
|
||||||
|
if (!isMounted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (startIndex === 0 && stopIndex === 0) {
|
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) {
|
if (isMounted.current) {
|
||||||
setCurrentlyLoading((prevCurrentlyLoading) =>
|
setCurrentlyLoading((prevCurrentlyLoading) =>
|
||||||
prevCurrentlyLoading.concat(loadRange)
|
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 = {
|
const params = {
|
||||||
...requestParams,
|
...requestParams,
|
||||||
...parseQueryString(QS_CONFIG, location.search),
|
...qs,
|
||||||
};
|
};
|
||||||
|
if (isFlatMode) {
|
||||||
|
params.not__stdout = '';
|
||||||
|
}
|
||||||
|
|
||||||
return getJobModel(job.type)
|
const model = getJobModel(job.type);
|
||||||
.readEvents(job.id, params)
|
|
||||||
.then((response) => {
|
|
||||||
if (!isMounted.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newResults = {};
|
let response;
|
||||||
let newResultsCssMap = {};
|
try {
|
||||||
response.data.results.forEach((jobEvent, index) => {
|
response = await model.readEvents(job.id, params);
|
||||||
newResults[firstIndex + index] = jobEvent;
|
} catch (error) {
|
||||||
const { lineCssMap } = getLineTextHtml(jobEvent);
|
if (error.response.status === 404) {
|
||||||
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
|
return;
|
||||||
});
|
}
|
||||||
setResults((prevResults) => ({
|
throw error;
|
||||||
...prevResults,
|
}
|
||||||
...newResults,
|
if (!isMounted.current) {
|
||||||
}));
|
return;
|
||||||
setCssMap((prevCssMap) => ({
|
}
|
||||||
...prevCssMap,
|
const events = response.data.results;
|
||||||
...newResultsCssMap,
|
const firstIndex = (params.page - 1) * params.page_size;
|
||||||
}));
|
|
||||||
setCurrentlyLoading((prevCurrentlyLoading) =>
|
let newCssMap;
|
||||||
prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
|
let rowNumber = firstIndex;
|
||||||
);
|
events.forEach((event) => {
|
||||||
loadRange.forEach((n) => {
|
event.rowNumber = rowNumber;
|
||||||
cache.clear(n);
|
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) => {
|
const scrollToRow = (rowIndex) => {
|
||||||
|
setLastScrollPosition(rowIndex);
|
||||||
if (listRef.current) {
|
if (listRef.current) {
|
||||||
listRef.current.scrollToRow(rowIndex);
|
listRef.current.scrollToRow(rowIndex);
|
||||||
}
|
}
|
||||||
@@ -385,6 +614,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
|
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
|
||||||
const scrollRange = stopIndex - startIndex + 1;
|
const scrollRange = stopIndex - startIndex + 1;
|
||||||
scrollToRow(Math.max(0, startIndex - scrollRange));
|
scrollToRow(Math.max(0, startIndex - scrollRange));
|
||||||
|
setIsFollowModeEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScrollNext = () => {
|
const handleScrollNext = () => {
|
||||||
@@ -394,10 +624,11 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
|
|
||||||
const handleScrollFirst = () => {
|
const handleScrollFirst = () => {
|
||||||
scrollToRow(0);
|
scrollToRow(0);
|
||||||
|
setIsFollowModeEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScrollLast = () => {
|
const handleScrollLast = () => {
|
||||||
scrollToRow(remoteRowCount - 1);
|
scrollToRow(totalNonCollapsedRows + wsEvents.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResize = ({ width }) => {
|
const handleResize = ({ width }) => {
|
||||||
@@ -470,7 +701,8 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
<InfiniteLoader
|
<InfiniteLoader
|
||||||
isRowLoaded={isRowLoaded}
|
isRowLoaded={isRowLoaded}
|
||||||
loadMoreRows={loadMoreRows}
|
loadMoreRows={loadMoreRows}
|
||||||
rowCount={remoteRowCount}
|
rowCount={totalNonCollapsedRows + wsEvents.length}
|
||||||
|
minimumBatchSize={50}
|
||||||
>
|
>
|
||||||
{({ onRowsRendered, registerChild }) => (
|
{({ onRowsRendered, registerChild }) => (
|
||||||
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
|
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
|
||||||
@@ -489,7 +721,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
|||||||
deferredMeasurementCache={cache}
|
deferredMeasurementCache={cache}
|
||||||
height={height || 1}
|
height={height || 1}
|
||||||
onRowsRendered={onRowsRendered}
|
onRowsRendered={onRowsRendered}
|
||||||
rowCount={remoteRowCount}
|
rowCount={totalNonCollapsedRows + wsEvents.length}
|
||||||
rowHeight={cache.rowHeight}
|
rowHeight={cache.rowHeight}
|
||||||
rowRenderer={rowRenderer}
|
rowRenderer={rowRenderer}
|
||||||
scrollToAlignment="start"
|
scrollToAlignment="start"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable max-len */
|
/* eslint-disable max-len */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { JobsAPI } from 'api';
|
import { JobsAPI, JobEventsAPI } from 'api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
import mockJobData from '../shared/data.job.json';
|
import mockJobData from '../shared/data.job.json';
|
||||||
import mockJobEventsData from './data.job_events.json';
|
import mockJobEventsData from './data.job_events.json';
|
||||||
import mockFilteredJobEventsData from './data.filtered_job_events.json';
|
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
|
|
||||||
@@ -27,73 +26,17 @@ const applyJobEventMock = (mockJobEvents) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents);
|
JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents);
|
||||||
};
|
JobEventsAPI.readChildren = jest.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
const generateChattyRows = () => {
|
results: [
|
||||||
const rows = [
|
{
|
||||||
'',
|
counter: 20,
|
||||||
'PLAY [all] *********************************************************************16:17:13',
|
uuid: 'abc-020',
|
||||||
'',
|
},
|
||||||
'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());
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
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 />', () => {
|
describe('<JobOutput />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -101,88 +44,18 @@ describe('<JobOutput />', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
applyJobEventMock(mockJobEventsData);
|
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', {
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 10,
|
value: 200,
|
||||||
});
|
});
|
||||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 100,
|
value: 100,
|
||||||
});
|
});
|
||||||
await act(async () => {
|
});
|
||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
|
||||||
});
|
afterEach(() => {
|
||||||
await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0);
|
jest.clearAllMocks();
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should make expected api call for delete', async () => {
|
test('should make expected api call for delete', async () => {
|
||||||
@@ -260,33 +133,6 @@ describe('<JobOutput />', () => {
|
|||||||
expect(wrapper.find('Search').props().isDisabled).toBe(true);
|
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 () => {
|
test('should throw error', async () => {
|
||||||
JobsAPI.readEvents = () => Promise.reject(new Error());
|
JobsAPI.readEvents = () => Promise.reject(new Error());
|
||||||
await act(async () => {
|
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';
|
import getRowRangePageSize from './shared/jobOutputUtils';
|
||||||
|
|
||||||
export default function getEventRequestParams(
|
export default function getEventRequestParams(
|
||||||
@@ -7,26 +6,19 @@ export default function getEventRequestParams(
|
|||||||
requestRange
|
requestRange
|
||||||
) {
|
) {
|
||||||
const [startIndex, stopIndex] = 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(
|
const { page, pageSize, firstIndex } = getRowRangePageSize(
|
||||||
startIndex,
|
startIndex,
|
||||||
stopIndex
|
stopIndex
|
||||||
);
|
);
|
||||||
const loadRange = range(
|
const loadRange = range(
|
||||||
firstIndex,
|
firstIndex + 1,
|
||||||
Math.min(firstIndex + pageSize, remoteRowCount)
|
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 = [];
|
const numbers = [];
|
||||||
for (let n = low; n <= high; n++) {
|
for (let n = low; n <= high; n++) {
|
||||||
numbers.push(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({
|
export default function getLineTextHtml({
|
||||||
created,
|
created,
|
||||||
event,
|
event,
|
||||||
start_line,
|
start_line: startLine,
|
||||||
stdout,
|
stdout,
|
||||||
}) {
|
}) {
|
||||||
const sanitized = encode(stdout);
|
const sanitized = encode(stdout);
|
||||||
@@ -95,7 +95,7 @@ export default function getLineTextHtml({
|
|||||||
}
|
}
|
||||||
|
|
||||||
lineTextHtml.push({
|
lineTextHtml.push({
|
||||||
lineNumber: start_line + index,
|
lineNumber: startLine + index,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,42 +1,41 @@
|
|||||||
import { getJobModel, isJobRunning } from 'util/jobs';
|
import { getJobModel } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export async function fetchCount(job, params) {
|
||||||
const {
|
const {
|
||||||
data: { count: eventCount },
|
data: { results: lastEvents = [] },
|
||||||
} = await eventPromise;
|
} = await getJobModel(job.type).readEvents(job.id, {
|
||||||
return eventCount;
|
...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;
|
let countOffset = 0;
|
||||||
if (job?.result_traceback) {
|
if (!job?.result_traceback) {
|
||||||
const tracebackEvent = {
|
return {
|
||||||
counter: 1,
|
events,
|
||||||
created: null,
|
countOffset,
|
||||||
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');
|
const tracebackEvent = {
|
||||||
stdoutLines[0] = tracebackEvent.stdout;
|
counter: 1,
|
||||||
events[firstIndex].stdout = stdoutLines.join('\r\n');
|
created: null,
|
||||||
} else {
|
event: null,
|
||||||
countOffset += 1;
|
type: null,
|
||||||
events.unshift(tracebackEvent);
|
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 {
|
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')};
|
cursor: ${(props) => (props.isClickable ? 'pointer' : 'default')};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover div {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
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;
|
background-color: #ebebeb;
|
||||||
color: #646972;
|
color: #646972;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -8,10 +11,34 @@ export default styled.div`
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
|
|
||||||
& > i {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
user-select: none;
|
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 JobEventLineNumber } from './JobEventLineNumber';
|
||||||
export { default as JobEventLineText } from './JobEventLineText';
|
export { default as JobEventLineText } from './JobEventLineText';
|
||||||
export { default as OutputToolbar } from './OutputToolbar';
|
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
Reference in New Issue
Block a user