390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
import React, { Component, PropTypes } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import Extensions from '@jenkins-cd/js-extensions';
|
|
import LogConsole from './LogConsole';
|
|
import * as sse from '@jenkins-cd/sse-gateway';
|
|
import { EmptyStateView } from '@jenkins-cd/design-language';
|
|
import { Icon } from 'react-material-icons-blue';
|
|
|
|
import LogToolbar from './LogToolbar';
|
|
import Steps from './Steps';
|
|
import {
|
|
steps as stepsSelector,
|
|
logs as logSelector,
|
|
node as nodeSelector,
|
|
nodes as nodesSelector,
|
|
actions,
|
|
connect,
|
|
createSelector,
|
|
} from '../redux';
|
|
|
|
import { calculateStepsBaseUrl, calculateRunLogURLObject, calculateNodeBaseUrl, calculateFetchAll } from '../util/UrlUtils';
|
|
import { calculateNode } from '../util/KaraokeHelper';
|
|
|
|
|
|
const { string, object, any, func } = PropTypes;
|
|
|
|
const queuedState = () => (
|
|
<EmptyStateView tightSpacing>
|
|
<p>
|
|
<Icon {...{
|
|
size: 20,
|
|
icon: 'timer',
|
|
style: { fill: '#fff' },
|
|
}} />
|
|
<span>Waiting for run to start.</span>
|
|
</p>
|
|
</EmptyStateView>
|
|
);
|
|
|
|
export class RunDetailsPipeline extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
// we do not want to follow any builds that are finished
|
|
this.state = { followAlong: props && props.result && props.result.state !== 'FINISHED' };
|
|
this.listener = {};
|
|
this._handleKeys = this._handleKeys.bind(this);
|
|
this.onScrollHandler = this.onScrollHandler.bind(this);
|
|
}
|
|
|
|
componentWillMount() {
|
|
const { fetchNodes, fetchLog, result } = this.props;
|
|
|
|
this.mergedConfig = this.generateConfig(this.props);
|
|
|
|
if (!result.isQueued()) {
|
|
// It should really be using capability using /rest/classes API
|
|
const supportsNode = result && result._class === 'io.jenkins.blueocean.rest.impl.pipeline.PipelineRunImpl';
|
|
if (supportsNode) {
|
|
fetchNodes(this.mergedConfig);
|
|
} else {
|
|
// console.log('fetch the log directly')
|
|
const logGeneral = calculateRunLogURLObject(this.mergedConfig);
|
|
// fetchAll indicates whether we want all logs
|
|
const fetchAll = this.mergedConfig.fetchAll;
|
|
fetchLog({ ...logGeneral, fetchAll });
|
|
}
|
|
}
|
|
|
|
this.listener.sse = sse.subscribe('pipeline', this._onSseEvent);
|
|
}
|
|
|
|
componentDidMount() {
|
|
const { result } = this.props;
|
|
|
|
if (!result.isQueued()) {
|
|
// determine scroll area
|
|
const domNode = ReactDOM.findDOMNode(this.refs.scrollArea);
|
|
// add both listemer, one to the scroll area and another to the whole document
|
|
domNode.addEventListener('wheel', this.onScrollHandler, false);
|
|
document.addEventListener('keydown', this._handleKeys, false);
|
|
}
|
|
}
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
if (this.props.result.isQueued()) {
|
|
return;
|
|
}
|
|
const followAlong = this.state.followAlong;
|
|
this.mergedConfig = this.generateConfig({ ...nextProps, followAlong });
|
|
|
|
// we do not want any timeouts if we are not doing karaoke
|
|
if (!this.state.followAlong && this.timeout) {
|
|
clearTimeout(this.timeout);
|
|
}
|
|
// calculate if we need to trigger any actions to get into the right state (is plain js for testing reasons)
|
|
const nodeAction = calculateNode(this.props, nextProps, this.mergedConfig);
|
|
if (nodeAction && nodeAction.action) {
|
|
// use updated config
|
|
this.mergedConfig = nodeAction.config;
|
|
// we may need to stop following
|
|
if (this.state.followAlong !== nodeAction.state.followAlong) {
|
|
this.setState({ followAlong: nodeAction.state.followAlong });
|
|
}
|
|
// if we have actions we fire them
|
|
this.props[nodeAction.action](this.mergedConfig);
|
|
}
|
|
const fetchAll = this.mergedConfig.fetchAll;
|
|
// console.log('this.mergedConfig.fetchAll', fetchAll)
|
|
// if we only interested in logs (in case of e.g. freestyle)
|
|
const { logs, fetchLog } = nextProps;
|
|
if (logs !== this.props.logs || fetchAll) {
|
|
const logGeneral = calculateRunLogURLObject(this.mergedConfig);
|
|
// console.log('logGenralReceive', logGeneral)
|
|
const log = logs ? logs[logGeneral.url] : null;
|
|
if (log && log !== null) {
|
|
// we may have a streaming log
|
|
const newStart = log.newStart;
|
|
if (Number(newStart) > 0) {
|
|
// in case we doing karaoke we want to see more logs
|
|
if (this.state.followAlong) {
|
|
// kill current timeout if any
|
|
clearTimeout(this.timeout);
|
|
// we need to get mpre input from the log stream
|
|
this.timeout = setTimeout(() => fetchLog({ ...logGeneral, newStart }), 1000);
|
|
}
|
|
}
|
|
} else if (fetchAll) {
|
|
// kill current timeout if any
|
|
clearTimeout(this.timeout);
|
|
// we need to get mpre input from the log stream
|
|
this.timeout = setTimeout(() => fetchLog({ ...logGeneral, fetchAll }), 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.listener.sse) {
|
|
sse.unsubscribe(this.listener.sse);
|
|
delete this.listener.sse;
|
|
}
|
|
|
|
if (this.props.result.isQueued()) {
|
|
return;
|
|
}
|
|
const domNode = ReactDOM.findDOMNode(this.refs.scrollArea);
|
|
this.props.cleanNodePointer();
|
|
clearTimeout(this.timeout);
|
|
domNode.removeEventListener('wheel', this._onScrollHandler);
|
|
document.removeEventListener('keydown', this._handleKeys);
|
|
}
|
|
|
|
// need to register handler to step out of karaoke mode
|
|
// we bail out on scroll up
|
|
onScrollHandler(elem) {
|
|
if (elem.deltaY < 0 && this.state.followAlong) {
|
|
this.setState({ followAlong: false });
|
|
}
|
|
}
|
|
|
|
// Listen for pipeline flow node events.
|
|
// We filter them only for steps and the end event all other we let pass
|
|
_onSseEvent(event) {
|
|
const { fetchNodes, fetchSteps } = this.props;
|
|
const jenkinsEvent = event.jenkins_event;
|
|
// we are using try/catch to throw an early out error
|
|
try {
|
|
if (event.pipeline_run_id !== this.props.result.id) {
|
|
// console.log('early out');
|
|
throw new Error('exit');
|
|
}
|
|
// we turn on refetch so we always fetch a new Node result
|
|
const refetch = true;
|
|
switch (jenkinsEvent) {
|
|
case 'pipeline_step':
|
|
{
|
|
// we are not using an early out for the events since we want to refresh the node if we finished
|
|
if (this.state.followAlong) { // if we do it means we want karaoke
|
|
// if the step_stage_id has changed we need to change the focus
|
|
if (event.pipeline_step_stage_id !== this.mergedConfig.node) {
|
|
// console.log('nodes fetching via sse triggered');
|
|
delete this.mergedConfig.node;
|
|
fetchNodes({ ...this.mergedConfig, refetch });
|
|
} else {
|
|
// console.log('only steps fetching via sse triggered');
|
|
fetchSteps({ ...this.mergedConfig, refetch });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'pipeline_end':
|
|
{
|
|
// we always want to refresh if the run has finished
|
|
fetchNodes({ ...this.mergedConfig, refetch });
|
|
break;
|
|
}
|
|
default:
|
|
{
|
|
// //console.log(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// we only ignore the exit error
|
|
if (e.message !== 'exit') {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
// we bail out on arrow_up key
|
|
_handleKeys(event) {
|
|
if (event.keyCode === 38 && this.state.followAlong) {
|
|
this.setState({ followAlong: false });
|
|
}
|
|
}
|
|
|
|
generateConfig(props) {
|
|
const { config = {} } = this.context;
|
|
const followAlong = this.state.followAlong;
|
|
const { isMultiBranch, params } = props;
|
|
const fetchAll = calculateFetchAll(props);
|
|
// we would use default properties however the node can be null so no default properties will be triggered
|
|
let { nodeReducer } = props;
|
|
if (!nodeReducer) {
|
|
nodeReducer = { id: null, displayName: 'Steps' };
|
|
}
|
|
// if we have a node param we do not want the calculation of the focused node
|
|
const node = params.node || nodeReducer.id;
|
|
|
|
// Merge config
|
|
return {
|
|
...config,
|
|
name: params.pipeline,
|
|
branch: params.branch,
|
|
runId: params.runId,
|
|
isMultiBranch,
|
|
node,
|
|
nodeReducer,
|
|
followAlong,
|
|
fetchAll,
|
|
};
|
|
}
|
|
|
|
render() {
|
|
const { location, router } = this.context;
|
|
|
|
const { isMultiBranch, steps, nodes, logs, result: run, params } = this.props;
|
|
|
|
if (run.isQueued()) {
|
|
return queuedState();
|
|
}
|
|
const resultRun = run.isCompleted() ? run.state : run.result;
|
|
const followAlong = this.state.followAlong;
|
|
// in certain cases we want that the log component will scroll to the end of a log
|
|
const scrollToBottom =
|
|
resultRun.toLowerCase() === 'failure'
|
|
|| (resultRun.toLowerCase() === 'running' && followAlong)
|
|
;
|
|
|
|
const nodeKey = calculateNodeBaseUrl(this.mergedConfig);
|
|
const key = calculateStepsBaseUrl(this.mergedConfig);
|
|
const logGeneral = calculateRunLogURLObject(this.mergedConfig);
|
|
const log = logs ? logs[logGeneral.url] : null;
|
|
let title = this.mergedConfig.nodeReducer.displayName;
|
|
if (log) {
|
|
title = 'Logs';
|
|
} else if (this.mergedConfig.nodeReducer.id !== null && title) {
|
|
title = `Steps - ${title}`;
|
|
}
|
|
const currentSteps = steps ? steps[key] : null;
|
|
// here we decide what to do next if somebody clicks on a flowNode
|
|
const afterClick = (id) => {
|
|
// get some information about the node the user clicked
|
|
const nodeInfo = nodes[nodeKey].model.filter((item) => item.id === id)[0];
|
|
const pathname = location.pathname;
|
|
let newPath;
|
|
// if path ends with pipeline we simply use it
|
|
if (pathname.endsWith('pipeline/')) {
|
|
newPath = pathname;
|
|
} else if (pathname.endsWith('pipeline')) {
|
|
newPath = `${pathname}/`;
|
|
} else {
|
|
// remove last bits
|
|
const pathArray = pathname.split('/');
|
|
pathArray.pop();
|
|
if (pathname.endsWith('/')) {
|
|
pathArray.pop();
|
|
}
|
|
pathArray.shift();
|
|
newPath = `${pathArray.join('/')}/`;
|
|
}
|
|
// we only want to redirect to the node if the node is finished
|
|
if (nodeInfo.state === 'FINISHED') {
|
|
newPath = `${newPath}${id}`;
|
|
}
|
|
// see whether we need to update the state
|
|
if (nodeInfo.state === 'FINISHED' && followAlong) {
|
|
this.setState({ followAlong: false });
|
|
}
|
|
if (nodeInfo.state !== 'FINISHED' && !followAlong) {
|
|
this.setState({ followAlong: true });
|
|
}
|
|
router.push(newPath);
|
|
};
|
|
const noSteps = !log && currentSteps && currentSteps.model && currentSteps.model.length === 0;
|
|
const shouldShowLogHeader = log !== null || !noSteps;
|
|
const logProps = {
|
|
scrollToBottom,
|
|
key: logGeneral.url,
|
|
};
|
|
if (log) {
|
|
// in follow along the Full Log button should not be shown, since you see everything already
|
|
if (followAlong) {
|
|
logProps.hasMore = false;
|
|
} else {
|
|
logProps.hasMore = log.hasMore;
|
|
}
|
|
logProps.logArray = log.logArray;
|
|
}
|
|
|
|
const stepScrollAreaClass = `step-scroll-area ${followAlong ? 'follow-along-on' : 'follow-along-off'}`;
|
|
|
|
return (
|
|
<div ref="scrollArea" className={stepScrollAreaClass}>
|
|
{ nodes && nodes[nodeKey] && <Extensions.Renderer
|
|
extensionPoint="jenkins.pipeline.run.result"
|
|
selectedStage={this.mergedConfig.nodeReducer}
|
|
callback={afterClick}
|
|
nodes={nodes[nodeKey].model}
|
|
pipelineName={name}
|
|
branchName={isMultiBranch ? params.branch : undefined}
|
|
runId={run.id}
|
|
/>
|
|
}
|
|
{ shouldShowLogHeader &&
|
|
<LogToolbar
|
|
fileName={logGeneral.fileName}
|
|
url={logGeneral.url}
|
|
title={title}
|
|
/>
|
|
}
|
|
{ currentSteps && <Steps
|
|
nodeInformation={currentSteps}
|
|
followAlong={followAlong}
|
|
router={router}
|
|
{...this.props}
|
|
/>
|
|
}
|
|
{ noSteps && <EmptyStateView tightSpacing>
|
|
<p>There are no steps.</p>
|
|
</EmptyStateView>
|
|
}
|
|
|
|
{ log && <LogConsole {...logProps} /> }
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
RunDetailsPipeline.propTypes = {
|
|
pipeline: object,
|
|
isMultiBranch: any,
|
|
params: object,
|
|
result: object,
|
|
fileName: string,
|
|
url: string,
|
|
fetchLog: func,
|
|
fetchNodes: func,
|
|
setNode: func,
|
|
fetchSteps: func,
|
|
cleanNodePointer: func,
|
|
logs: object,
|
|
steps: object,
|
|
nodes: object,
|
|
nodeReducer: object,
|
|
};
|
|
|
|
RunDetailsPipeline.contextTypes = {
|
|
config: object.isRequired,
|
|
params: object,
|
|
pipeline: object,
|
|
router: object.isRequired, // From react-router
|
|
location: object.isRequired, // From react-router
|
|
};
|
|
|
|
const selectors = createSelector(
|
|
[stepsSelector, logSelector, nodeSelector, nodesSelector],
|
|
(steps, logs, nodeReducer, nodes) => ({ steps, logs, nodeReducer, nodes }));
|
|
|
|
export default connect(selectors, actions)(RunDetailsPipeline);
|