JobOutput: extract JobOutputPane

This commit is contained in:
Keith J. Grant
2021-08-17 12:48:51 -07:00
parent 949c2b92af
commit c8604c73a9
2 changed files with 324 additions and 269 deletions

View File

@@ -2,19 +2,12 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { import { CellMeasurerCache } from 'react-virtualized';
AutoSizer,
CellMeasurer,
CellMeasurerCache,
InfiniteLoader,
List,
} from 'react-virtualized';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import AlertModal from 'components/AlertModal'; import AlertModal from 'components/AlertModal';
import { CardBody as _CardBody } from 'components/Card'; import { CardBody as _CardBody } from 'components/Card';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
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';
@@ -23,11 +16,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import useInterval from 'hooks/useInterval'; import useInterval from 'hooks/useInterval';
import { parseQueryString, getQSConfig } from 'util/qs'; import { parseQueryString, getQSConfig } from 'util/qs';
import useIsMounted from 'hooks/useIsMounted'; import useIsMounted from 'hooks/useIsMounted';
import JobEvent from './JobEvent'; import JobOutputPane from './JobOutputPane';
import JobEventSkeleton from './JobEventSkeleton';
import PageControls from './PageControls';
import HostEventModal from './HostEventModal';
import JobOutputSearch from './JobOutputSearch';
import { HostStatusBar, OutputToolbar } from './shared'; import { HostStatusBar, OutputToolbar } from './shared';
import getRowRangePageSize from './shared/jobOutputUtils'; import getRowRangePageSize from './shared/jobOutputUtils';
import getLineTextHtml from './getLineTextHtml'; import getLineTextHtml from './getLineTextHtml';
@@ -56,27 +45,6 @@ const OutputHeader = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
const OutputWrapper = styled.div`
background-color: #ffffff;
display: flex;
flex-direction: column;
flex: 1 1 auto;
font-family: monospace;
font-size: 15px;
outline: 1px solid #d7d7d7;
${({ cssMap }) =>
Object.keys(cssMap).map(
(className) => `.${className}{${cssMap[className]}}`
)}
`;
const OutputFooter = styled.div`
background-color: #ebebeb;
border-right: 1px solid #d7d7d7;
width: 75px;
flex: 1;
`;
let ws; let ws;
function connectJobSocket({ type, id }, onMessage) { function connectJobSocket({ type, id }, onMessage) {
ws = new WebSocket( ws = new WebSocket(
@@ -129,23 +97,6 @@ function range(low, high) {
return numbers; return numbers;
} }
function isHostEvent(jobEvent) {
const { event, event_data, host, type } = jobEvent;
let isHost;
if (typeof host === 'number' || (event_data && event_data.res)) {
isHost = true;
} else if (
type === 'project_update_event' &&
event !== 'runner_on_skipped' &&
event_data.host
) {
isHost = true;
} else {
isHost = false;
}
return isHost;
}
const cache = new CellMeasurerCache({ const cache = new CellMeasurerCache({
fixedWidth: true, fixedWidth: true,
defaultHeight: 25, defaultHeight: 25,
@@ -175,18 +126,13 @@ const getEventRequestParams = (job, remoteRowCount, requestRange) => {
function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
const location = useLocation(); const location = useLocation();
const listRef = useRef(null); const listRef = useRef(null);
const previousWidth = useRef(0);
const jobSocketCounter = useRef(0); const jobSocketCounter = useRef(0);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const scrollTop = useRef(0);
const scrollHeight = useRef(0);
const history = useHistory(); const history = useHistory();
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [cssMap, setCssMap] = useState({}); const [cssMap, setCssMap] = useState({});
const [currentlyLoading, setCurrentlyLoading] = useState([]); const [currentlyLoading, setCurrentlyLoading] = useState([]);
const [hasContentLoading, setHasContentLoading] = useState(true); const [hasContentLoading, setHasContentLoading] = useState(true);
const [hostEvent, setHostEvent] = useState({});
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting'); const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting');
const [showCancelModal, setShowCancelModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false);
const [remoteRowCount, setRemoteRowCount] = useState(0); const [remoteRowCount, setRemoteRowCount] = useState(0);
@@ -203,6 +149,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
isMonitoringWebsocket ? 5000 : null isMonitoringWebsocket ? 5000 : null
); );
// A
useEffect(() => { useEffect(() => {
loadJobEvents(); loadJobEvents();
@@ -234,12 +181,14 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}; };
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps }, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
// B
useEffect(() => { useEffect(() => {
if (listRef.current?.recomputeRowHeights) { if (listRef.current?.recomputeRowHeights) {
listRef.current.recomputeRowHeights(); listRef.current.recomputeRowHeights();
} }
}, [currentlyLoading, cssMap, remoteRowCount]); }, [currentlyLoading, cssMap, remoteRowCount]);
// C
useEffect(() => { useEffect(() => {
if (jobStatus && !isJobRunning(jobStatus)) { if (jobStatus && !isJobRunning(jobStatus)) {
if (jobSocketCounter.current > remoteRowCount && isMounted.current) { if (jobSocketCounter.current > remoteRowCount && isMounted.current) {
@@ -394,165 +343,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
} }
}; };
const isRowLoaded = ({ index }) => {
if (results[index]) {
return true;
}
return currentlyLoading.includes(index);
};
const handleHostEventClick = (hostEventToOpen) => {
setHostEvent(hostEventToOpen);
setIsHostModalOpen(true);
};
const handleHostModalClose = () => {
setIsHostModalOpen(false);
};
const rowRenderer = ({ index, parent, key, style }) => {
if (listRef.current && isFollowModeEnabled) {
setTimeout(() => scrollToRow(remoteRowCount - 1), 0);
}
let actualLineTextHtml = [];
if (results[index]) {
const { lineTextHtml } = getLineTextHtml(results[index]);
actualLineTextHtml = lineTextHtml;
}
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
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}
/>
)}
</CellMeasurer>
);
};
const loadMoreRows = ({ startIndex, stopIndex }) => {
if (startIndex === 0 && stopIndex === 0) {
return Promise.resolve(null);
}
if (stopIndex > startIndex + 50) {
stopIndex = startIndex + 50;
}
const [requestParams, loadRange, firstIndex] = getEventRequestParams(
job,
remoteRowCount,
[startIndex, stopIndex]
);
if (isMounted.current) {
setCurrentlyLoading((prevCurrentlyLoading) =>
prevCurrentlyLoading.concat(loadRange)
);
}
const params = {
...requestParams,
...parseQueryString(QS_CONFIG, location.search),
};
return getJobModel(job.type)
.readEvents(job.id, params)
.then((response) => {
if (isMounted.current) {
const newResults = {};
let newResultsCssMap = {};
response.data.results.forEach((jobEvent, index) => {
newResults[firstIndex + index] = jobEvent;
const { lineCssMap } = getLineTextHtml(jobEvent);
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
});
setResults((prevResults) => ({
...prevResults,
...newResults,
}));
setCssMap((prevCssMap) => ({
...prevCssMap,
...newResultsCssMap,
}));
setCurrentlyLoading((prevCurrentlyLoading) =>
prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
);
loadRange.forEach((n) => {
cache.clear(n);
});
}
});
};
const scrollToRow = (rowIndex) => {
if (listRef.current) {
listRef.current.scrollToRow(rowIndex);
}
};
const handleScrollPrevious = () => {
const startIndex = listRef.current.Grid._renderedRowStartIndex;
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
const scrollRange = stopIndex - startIndex + 1;
scrollToRow(Math.max(0, startIndex - scrollRange));
};
const handleScrollNext = () => {
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
scrollToRow(stopIndex - 1);
};
const handleScrollFirst = () => {
scrollToRow(0);
};
const handleScrollLast = () => {
scrollToRow(remoteRowCount - 1);
};
const handleResize = ({ width }) => {
if (width !== previousWidth) {
cache.clearAll();
if (listRef.current?.recomputeRowHeights) {
listRef.current.recomputeRowHeights();
}
}
previousWidth.current = width;
};
const handleScroll = (e) => {
if (
isFollowModeEnabled &&
scrollTop.current > e.scrollTop &&
scrollHeight.current === e.scrollHeight
) {
setIsFollowModeEnabled(false);
}
scrollTop.current = e.scrollTop;
scrollHeight.current = e.scrollHeight;
};
if (contentError) { if (contentError) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
@@ -560,13 +350,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
return ( return (
<> <>
<CardBody> <CardBody>
{isHostModalOpen && (
<HostEventModal
onClose={handleHostModalClose}
isOpen={isHostModalOpen}
hostEvent={hostEvent}
/>
)}
<OutputHeader> <OutputHeader>
<HeaderTitle> <HeaderTitle>
<StatusIcon status={job.status} /> <StatusIcon status={job.status} />
@@ -581,61 +364,25 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
/> />
</OutputHeader> </OutputHeader>
<HostStatusBar counts={job.host_status_counts} /> <HostStatusBar counts={job.host_status_counts} />
<JobOutputSearch <JobOutputPane
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
job={job} job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys} eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys} eventSearchableKeys={eventSearchableKeys}
results={results}
setResults={setResults}
currentlyLoading={currentlyLoading}
setCurrentlyLoading={setCurrentlyLoading}
hasContentLoading={hasContentLoading}
listRef={listRef}
remoteRowCount={remoteRowCount} remoteRowCount={remoteRowCount}
scrollToRow={scrollToRow}
isFollowModeEnabled={isFollowModeEnabled} isFollowModeEnabled={isFollowModeEnabled}
setIsFollowModeEnabled={setIsFollowModeEnabled} setIsFollowModeEnabled={setIsFollowModeEnabled}
cache={cache}
cssMap={cssMap}
setCssMap={setCssMap}
getEventRequestParams={getEventRequestParams}
/> />
<PageControls
onScrollFirst={handleScrollFirst}
onScrollLast={handleScrollLast}
onScrollNext={handleScrollNext}
onScrollPrevious={handleScrollPrevious}
/>
<OutputWrapper cssMap={cssMap}>
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={remoteRowCount}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
{({ width, height }) => (
<>
{hasContentLoading ? (
<div style={{ width }}>
<ContentLoading />
</div>
) : (
<List
ref={(ref) => {
registerChild(ref);
listRef.current = ref;
}}
deferredMeasurementCache={cache}
height={height || 1}
onRowsRendered={onRowsRendered}
rowCount={remoteRowCount}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
scrollToAlignment="start"
width={width || 1}
overscanRowCount={20}
onScroll={handleScroll}
/>
)}
</>
)}
</AutoSizer>
)}
</InfiniteLoader>
<OutputFooter />
</OutputWrapper>
</CardBody> </CardBody>
{showCancelModal && isJobRunning(job.status) && ( {showCancelModal && isJobRunning(job.status) && (
<AlertModal <AlertModal

View File

@@ -0,0 +1,308 @@
import React, { useState, useRef } from 'react';
import styled from 'styled-components';
import { useLocation } from 'react-router-dom';
import {
AutoSizer,
CellMeasurer,
InfiniteLoader,
List,
} from 'react-virtualized';
import ContentLoading from 'components/ContentLoading';
import { parseQueryString } from 'util/qs';
import { getJobModel } from 'util/jobs';
import useIsMounted from 'hooks/useIsMounted';
import PageControls from './PageControls';
import JobOutputSearch from './JobOutputSearch';
import JobEvent from './JobEvent';
import JobEventSkeleton from './JobEventSkeleton';
import HostEventModal from './HostEventModal';
import getLineTextHtml from './getLineTextHtml';
const OutputWrapper = styled.div`
background-color: #ffffff;
display: flex;
flex-direction: column;
flex: 1 1 auto;
font-family: monospace;
font-size: 15px;
outline: 1px solid #d7d7d7;
${({ cssMap }) =>
Object.keys(cssMap).map(
(className) => `.${className}{${cssMap[className]}}`
)}
`;
const OutputFooter = styled.div`
background-color: #ebebeb;
border-right: 1px solid #d7d7d7;
width: 75px;
flex: 1;
`;
function isHostEvent(jobEvent) {
const { event, event_data, host, type } = jobEvent;
let isHost;
if (typeof host === 'number' || (event_data && event_data.res)) {
isHost = true;
} else if (
type === 'project_update_event' &&
event !== 'runner_on_skipped' &&
event_data.host
) {
isHost = true;
} else {
isHost = false;
}
return isHost;
}
export default function JobOutputPane({
qsConfig,
job,
eventRelatedSearchableKeys,
eventSearchableKeys,
results,
setResults,
currentlyLoading,
setCurrentlyLoading,
hasContentLoading,
listRef,
remoteRowCount,
isFollowModeEnabled,
setIsFollowModeEnabled,
cache,
cssMap,
setCssMap,
getEventRequestParams,
}) {
const previousWidth = useRef(0);
const scrollTop = useRef(0);
const scrollHeight = useRef(0);
const isMounted = useIsMounted();
const location = useLocation();
const [hostEvent, setHostEvent] = useState({});
const [isHostModalOpen, setIsHostModalOpen] = useState(false);
const isRowLoaded = ({ index }) => {
if (results[index]) {
return true;
}
return currentlyLoading.includes(index);
};
const handleHostEventClick = (hostEventToOpen) => {
setHostEvent(hostEventToOpen);
setIsHostModalOpen(true);
};
const scrollToRow = (rowIndex) => {
if (listRef.current) {
listRef.current.scrollToRow(rowIndex);
}
};
const rowRenderer = ({ index, parent, key, style }) => {
if (listRef.current && isFollowModeEnabled) {
setTimeout(() => scrollToRow(remoteRowCount - 1), 0);
}
let actualLineTextHtml = [];
if (results[index]) {
const { lineTextHtml } = getLineTextHtml(results[index]);
actualLineTextHtml = lineTextHtml;
}
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
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}
/>
)}
</CellMeasurer>
);
};
const loadMoreRows = ({ startIndex, stopIndex }) => {
if (startIndex === 0 && stopIndex === 0) {
return Promise.resolve(null);
}
if (stopIndex > startIndex + 50) {
stopIndex = startIndex + 50;
}
const [requestParams, loadRange, firstIndex] = getEventRequestParams(
job,
remoteRowCount,
[startIndex, stopIndex]
);
if (isMounted.current) {
setCurrentlyLoading((prevCurrentlyLoading) =>
prevCurrentlyLoading.concat(loadRange)
);
}
const params = {
...requestParams,
...parseQueryString(qsConfig, location.search),
};
return getJobModel(job.type)
.readEvents(job.id, params)
.then((response) => {
if (isMounted.current) {
const newResults = {};
let newResultsCssMap = {};
response.data.results.forEach((jobEvent, index) => {
newResults[firstIndex + index] = jobEvent;
const { lineCssMap } = getLineTextHtml(jobEvent);
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
});
setResults((prevResults) => ({
...prevResults,
...newResults,
}));
setCssMap((prevCssMap) => ({
...prevCssMap,
...newResultsCssMap,
}));
setCurrentlyLoading((prevCurrentlyLoading) =>
prevCurrentlyLoading.filter((n) => !loadRange.includes(n))
);
loadRange.forEach((n) => {
cache.clear(n);
});
}
});
};
const handleResize = ({ width }) => {
if (width !== previousWidth) {
cache.clearAll();
if (listRef.current?.recomputeRowHeights) {
listRef.current.recomputeRowHeights();
}
}
previousWidth.current = width;
};
const handleScroll = (e) => {
if (
isFollowModeEnabled &&
scrollTop.current > e.scrollTop &&
scrollHeight.current === e.scrollHeight
) {
setIsFollowModeEnabled(false);
}
scrollTop.current = e.scrollTop;
scrollHeight.current = e.scrollHeight;
};
const handleScrollPrevious = () => {
const startIndex = listRef.current.Grid._renderedRowStartIndex;
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
const scrollRange = stopIndex - startIndex + 1;
scrollToRow(Math.max(0, startIndex - scrollRange));
};
const handleScrollNext = () => {
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
scrollToRow(stopIndex - 1);
};
const handleScrollFirst = () => {
scrollToRow(0);
};
const handleScrollLast = () => {
scrollToRow(remoteRowCount - 1);
};
return (
<>
<JobOutputSearch
qsConfig={qsConfig}
job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys}
remoteRowCount={remoteRowCount}
scrollToRow={scrollToRow}
isFollowModeEnabled={isFollowModeEnabled}
setIsFollowModeEnabled={setIsFollowModeEnabled}
/>
<PageControls
onScrollFirst={handleScrollFirst}
onScrollLast={handleScrollLast}
onScrollNext={handleScrollNext}
onScrollPrevious={handleScrollPrevious}
/>
<OutputWrapper cssMap={cssMap}>
{isHostModalOpen && (
<HostEventModal
onClose={() => setIsHostModalOpen(false)}
isOpen={isHostModalOpen}
hostEvent={hostEvent}
/>
)}
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={remoteRowCount}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
{({ width, height }) => (
<>
{hasContentLoading ? (
<div style={{ width }}>
<ContentLoading />
</div>
) : (
<List
ref={(ref) => {
registerChild(ref);
listRef.current = ref;
}}
deferredMeasurementCache={cache}
height={height || 1}
onRowsRendered={onRowsRendered}
rowCount={remoteRowCount}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
scrollToAlignment="start"
width={width || 1}
overscanRowCount={20}
onScroll={handleScroll}
/>
)}
</>
)}
</AutoSizer>
)}
</InfiniteLoader>
<OutputFooter />
</OutputWrapper>
</>
);
}