Merge pull request #400 from jenkinsci/feature/JENKINS-36582-favorites-realtime
Feature/jenkins 36582 favorites realtime
This commit is contained in:
commit
df13370eb8
|
@ -35,7 +35,7 @@
|
|||
"skin-deep": "^0.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/design-language": "0.0.67",
|
||||
"@jenkins-cd/design-language": "0.0.68",
|
||||
"@jenkins-cd/js-extensions": "0.0.21-beta4",
|
||||
"@jenkins-cd/js-modules": "0.0.5",
|
||||
"@jenkins-cd/sse-gateway": "0.0.7",
|
||||
|
|
|
@ -27,6 +27,7 @@ import hudson.Extension;
|
|||
import hudson.model.Item;
|
||||
import hudson.model.ItemGroup;
|
||||
import io.jenkins.blueocean.rest.hal.Link;
|
||||
import io.jenkins.blueocean.rest.hal.LinkResolver;
|
||||
import io.jenkins.blueocean.service.embedded.rest.OrganizationImpl;
|
||||
import jenkins.model.ParameterizedJobMixIn;
|
||||
import org.jenkins.pubsub.EventProps;
|
||||
|
@ -38,7 +39,6 @@ import org.jenkinsci.plugins.workflow.job.WorkflowJob;
|
|||
import org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.net.URLEncoder;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:tom.fennelly@gmail.com">tom.fennelly@gmail.com</a>
|
||||
|
@ -55,14 +55,14 @@ public class BlueMessageEnricher extends MessageEnricher {
|
|||
@Override
|
||||
public void enrich(@Nonnull Message message) {
|
||||
|
||||
// TODO: Replace once https://issues.jenkins-ci.org/browse/JENKINS-36286 is done
|
||||
// TODO: Get organization name in generic way once multi-organization support is implemented in API
|
||||
message.set(EventProps.Jenkins.jenkins_org, OrganizationImpl.INSTANCE.getName());
|
||||
|
||||
String channelName = message.getChannelName();
|
||||
if (channelName.equals(Events.JobChannel.NAME)) {
|
||||
JobChannelMessage jobChannelMessage = (JobChannelMessage) message;
|
||||
ParameterizedJobMixIn.ParameterizedJob job = jobChannelMessage.getJob();
|
||||
Link jobUrl = getLink(job);
|
||||
Link jobUrl = LinkResolver.resolveLink(job);
|
||||
|
||||
jobChannelMessage.set(BlueEventProps.blueocean_job_rest_url, jobUrl.getHref());
|
||||
jobChannelMessage.set(BlueEventProps.blueocean_job_pipeline_name, job.getFullName());
|
||||
|
@ -77,21 +77,4 @@ public class BlueMessageEnricher extends MessageEnricher {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Replace once https://issues.jenkins-ci.org/browse/JENKINS-36286 is done
|
||||
private static @Nonnull Link getLink(@Nonnull ParameterizedJobMixIn.ParameterizedJob job) {
|
||||
Link orgLink = new Link("/rest/organizations/" + OrganizationImpl.INSTANCE.getName());
|
||||
|
||||
if (job instanceof WorkflowJob) {
|
||||
ItemGroup<? extends Item> parent = job.getParent();
|
||||
if (parent instanceof WorkflowMultiBranchProject) {
|
||||
String multiBranchProjectName = parent.getFullName();
|
||||
//MN I hate everything about this. Branch names must be encoded, even if they are already URL encoded, they need to be twice endcode
|
||||
// eg foo/bar -> foo%2Fbar -> foo%252F. The latter form is what is required by API and classic URIs.
|
||||
return orgLink.rel("pipelines").rel(multiBranchProjectName).rel("branches").rel(URLEncoder.encode(job.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
return orgLink.rel("pipelines").rel(job.getFullName());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
"react-addons-test-utils": "15.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/design-language": "0.0.67",
|
||||
"@jenkins-cd/design-language": "0.0.68",
|
||||
"@jenkins-cd/js-extensions": "0.0.21-beta4",
|
||||
"@jenkins-cd/js-modules": "0.0.5",
|
||||
"@jenkins-cd/sse-gateway": "0.0.7",
|
||||
|
|
|
@ -10,10 +10,14 @@
|
|||
<name>BlueOcean :: Personalization</name>
|
||||
<artifactId>blueocean-personalization</artifactId>
|
||||
<packaging>hpi</packaging>
|
||||
|
||||
|
||||
<url>https://wiki.jenkins-ci.org/display/JENKINS/Blue+Ocean+Plugin</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-events</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { List } from 'immutable';
|
|||
|
||||
import { favoritesSelector } from '../redux/FavoritesStore';
|
||||
import { actions } from '../redux/FavoritesActions';
|
||||
import favoritesSseListener from '../model/FavoritesSseListener';
|
||||
|
||||
import FavoritesProvider from './FavoritesProvider';
|
||||
import { PipelineCard } from './PipelineCard';
|
||||
|
@ -89,9 +90,17 @@ const extractPath = (path, begin, end) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Renders a stack of "favorites cards" including current most recent status.
|
||||
*/
|
||||
export class DashboardCards extends Component {
|
||||
|
||||
componentWillMount() {
|
||||
favoritesSseListener.initialize(
|
||||
this.props.store,
|
||||
this.props.updateRun
|
||||
);
|
||||
}
|
||||
|
||||
_onFavoriteToggle(isFavorite, favorite) {
|
||||
this.props.toggleFavorite(isFavorite, favorite.item, favorite);
|
||||
}
|
||||
|
@ -112,7 +121,7 @@ export class DashboardCards extends Component {
|
|||
let branchName;
|
||||
|
||||
if (pipeline._class === 'io.jenkins.blueocean.rest.impl.pipeline.BranchImpl') {
|
||||
// branch.fullName is in the form folder1/folder2/pipeline/branch ...
|
||||
// pipeline.fullName is in the form folder1/folder2/pipeline/branch ...
|
||||
// "pipeline"
|
||||
pipelineName = extractPath(pipeline.fullName, -2, -1);
|
||||
// everything up to "branch"
|
||||
|
@ -167,8 +176,8 @@ export class DashboardCards extends Component {
|
|||
return (
|
||||
<div className="favorites-card-stack">
|
||||
<TransitionGroup transitionName="vertical-expand-collapse"
|
||||
transitionEnterTimeout={150}
|
||||
transitionLeaveTimeout={150}
|
||||
transitionEnterTimeout={300}
|
||||
transitionLeaveTimeout={300}
|
||||
>
|
||||
{favoriteCards}
|
||||
</TransitionGroup>
|
||||
|
@ -190,6 +199,7 @@ DashboardCards.propTypes = {
|
|||
router: PropTypes.object,
|
||||
favorites: PropTypes.instanceOf(List),
|
||||
toggleFavorite: PropTypes.func,
|
||||
updateRun: PropTypes.func,
|
||||
};
|
||||
|
||||
const selectors = createSelector(
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Created by cmeyers on 8/12/16.
|
||||
*/
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import * as sse from '@jenkins-cd/sse-gateway';
|
||||
|
||||
import { SseBus } from '../model/SseBus';
|
||||
import { checkMatchingFavoriteUrls } from '../util/FavoriteUtils';
|
||||
|
||||
/**
|
||||
* Class that acts as a bridge between SSE and the store/actions.
|
||||
* Needs to be a "Singleton" so the subscription can be maintained across route changes.
|
||||
* TODO: should cleaner way of registering a long-lived component which can easily access stores, services, etc.
|
||||
*/
|
||||
class FavoritesSseListener {
|
||||
|
||||
initialize(store, jobListener) {
|
||||
// prevent more than one registration
|
||||
if (this.store && this.sseBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store = store;
|
||||
this.sseBus = new SseBus(sse, fetch);
|
||||
this.sseBus.subscribeToJob(
|
||||
jobListener,
|
||||
(event) => this._filterJobs(event)
|
||||
);
|
||||
}
|
||||
|
||||
_filterJobs(event) {
|
||||
const favorites = this.store.getState().favoritesStore.get('favorites');
|
||||
|
||||
// suppress processing of any events whose job URL doesn't match the favorited item's URL
|
||||
if (favorites && favorites.size > 0) {
|
||||
for (const favorite of favorites) {
|
||||
const favoriteUrl = favorite.item._links.self.href;
|
||||
const pipelineOrBranchUrl = event.blueocean_job_rest_url;
|
||||
|
||||
if (checkMatchingFavoriteUrls(favoriteUrl, pipelineOrBranchUrl)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new FavoritesSseListener();
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Created by cmeyers on 7/29/16.
|
||||
*/
|
||||
import defaultFetch from 'isomorphic-fetch';
|
||||
|
||||
import urlConfig from '../config';
|
||||
urlConfig.loadConfig();
|
||||
|
||||
const defaultFetchOptions = {
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
|
||||
/**
|
||||
* Trims duplicate forward slashes to a single slash and adds trailing slash if needed.
|
||||
* @param url
|
||||
* @returns {string}
|
||||
*/
|
||||
export const cleanSlashes = (url) => {
|
||||
if (url.indexOf('//') !== -1) {
|
||||
let cleanUrl = url.replace('//', '/');
|
||||
cleanUrl = cleanUrl.substr(-1) === '/' ?
|
||||
cleanUrl : `${cleanUrl}/`;
|
||||
|
||||
return cleanSlashes(cleanUrl);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
// TODO: migrate all this code down to 'fetch'
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
return response.json()
|
||||
// FIXME: workaround for status=200 w/ empty response body that causes error in Chrome
|
||||
// server should probably return HTTP 204 instead
|
||||
.catch((error) => {
|
||||
if (error.message === 'Unexpected end of JSON input') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function clone(json) {
|
||||
return JSON.parse(JSON.stringify(json));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the SSE Gateway and fetches data related to events from REST API.
|
||||
*/
|
||||
export class SseBus {
|
||||
|
||||
constructor(sse, fetch) {
|
||||
this.sse = sse;
|
||||
this.fetch = fetch || defaultFetch;
|
||||
this.jobListenerSse = null;
|
||||
this.jobListenerExternal = null;
|
||||
this.jobFilter = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.jobListenerSse) {
|
||||
this.sse.unsubscribe(this.jobListenerSse);
|
||||
this.jobListenerSse = null;
|
||||
}
|
||||
|
||||
if (this.jobListenerExternal) {
|
||||
this.jobListenerExternal = null;
|
||||
}
|
||||
|
||||
if (this.jobFilter) {
|
||||
this.jobFilter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to job events.
|
||||
* @param callback func to invoke with job data
|
||||
* @param jobFilter func invoked for each job event, return false to suppress callback invocation
|
||||
*/
|
||||
subscribeToJob(callback, jobFilter) {
|
||||
this.jobListenerExternal = callback;
|
||||
this.jobListenerSse = this.sse.subscribe('job', (event) => {
|
||||
this._handleJobEvent(event);
|
||||
});
|
||||
this.jobFilter = jobFilter;
|
||||
}
|
||||
|
||||
_handleJobEvent(event) {
|
||||
// if the filter is not interested in the event, bail
|
||||
if (this.jobFilter && !this.jobFilter(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.jenkins_event) {
|
||||
case 'job_crud_created':
|
||||
case 'job_crud_deleted':
|
||||
case 'job_crud_renamed':
|
||||
this._refetchPipelines();
|
||||
break;
|
||||
case 'job_run_queue_buildable':
|
||||
case 'job_run_queue_enter':
|
||||
this._enqueueJob(event);
|
||||
break;
|
||||
case 'job_run_queue_left':
|
||||
case 'job_run_queue_blocked': {
|
||||
break;
|
||||
}
|
||||
case 'job_run_started': {
|
||||
this._updateJob(event);
|
||||
break;
|
||||
}
|
||||
case 'job_run_ended': {
|
||||
this._updateJob(event);
|
||||
break;
|
||||
}
|
||||
default :
|
||||
// Else ignore the event.
|
||||
}
|
||||
}
|
||||
|
||||
_refetchPipelines() {
|
||||
// TODO: implement once migration into commons JS
|
||||
}
|
||||
|
||||
_enqueueJob(event) {
|
||||
const queuedRun = {
|
||||
event,
|
||||
};
|
||||
|
||||
queuedRun.pipeline = event.job_ismultibranch ?
|
||||
event.blueocean_job_branch_name :
|
||||
event.blueocean_job_pipeline_name;
|
||||
|
||||
const runUrl = cleanSlashes(`${event.blueocean_job_rest_url}/runs/${event.job_run_queueId}`);
|
||||
|
||||
queuedRun._links = {
|
||||
self: {
|
||||
href: runUrl,
|
||||
},
|
||||
};
|
||||
|
||||
queuedRun.state = 'QUEUED';
|
||||
queuedRun.result = 'UNKNOWN';
|
||||
|
||||
if (this.jobListenerExternal) {
|
||||
this.jobListenerExternal(queuedRun);
|
||||
}
|
||||
}
|
||||
|
||||
_updateJob(event) {
|
||||
const baseUrl = urlConfig.jenkinsRootURL;
|
||||
const url = cleanSlashes(`${baseUrl}/${event.blueocean_job_rest_url}/runs/${event.jenkins_object_id}`);
|
||||
|
||||
this.fetch(url, defaultFetchOptions)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON)
|
||||
.then((data) => {
|
||||
const updatedRun = clone(data);
|
||||
|
||||
// in many cases the SSE and subsequent REST call occur so quickly
|
||||
// that the run's state is stale. force the state to the correct value.
|
||||
if (event.jenkins_event === 'job_run_ended') {
|
||||
updatedRun.state = 'FINISHED';
|
||||
} else {
|
||||
updatedRun.state = 'RUNNING';
|
||||
}
|
||||
|
||||
if (this.jobListenerExternal) {
|
||||
this.jobListenerExternal(updatedRun);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_updateMultiBranchPipelineBranches() {
|
||||
// TODO: implement once migration into commons JS
|
||||
}
|
||||
}
|
|
@ -105,6 +105,15 @@ export const actions = {
|
|||
};
|
||||
},
|
||||
|
||||
updateRun(jobRun) {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.UPDATE_RUN,
|
||||
jobRun,
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
generateData(request, actionType, optional) {
|
||||
const { url, fetchOptions } = request;
|
||||
return (dispatch) => fetch(url, fetchOptions)
|
||||
|
|
|
@ -21,8 +21,13 @@ export const ACTION_TYPES = keymirror({
|
|||
SET_USER: null,
|
||||
SET_FAVORITES: null,
|
||||
TOGGLE_FAVORITE: null,
|
||||
UPDATE_RUN: null,
|
||||
});
|
||||
|
||||
function clone(json) {
|
||||
return JSON.parse(JSON.stringify(json));
|
||||
}
|
||||
|
||||
const actionHandlers = {
|
||||
[ACTION_TYPES.SET_USER](state, { payload }) {
|
||||
const user = new User(payload);
|
||||
|
@ -52,6 +57,27 @@ const actionHandlers = {
|
|||
|
||||
return state.set('favorites', prunedList);
|
||||
},
|
||||
[ACTION_TYPES.UPDATE_RUN](state, { jobRun }) {
|
||||
const favorites = state.get('favorites');
|
||||
|
||||
for (const fav of favorites) {
|
||||
const runsBaseUrl = `${fav.item._links.self.href}runs`;
|
||||
const runUrl = jobRun._links.self.href;
|
||||
|
||||
// if the job's run URL starts with the favorited item's '/runs' URL,
|
||||
// then the run applies to that item, so update the 'latestRun' property
|
||||
if (runUrl.indexOf(runsBaseUrl) === 0) {
|
||||
const index = favorites.indexOf(fav);
|
||||
const updatedFavorite = clone(fav);
|
||||
updatedFavorite.item.latestRun = jobRun;
|
||||
const updatedFavorites = favorites.set(index, updatedFavorite);
|
||||
return state.set('favorites', updatedFavorites);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('run was not updated; likely an error?');
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
const favoritesStore = state => state.favoritesStore;
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
}
|
||||
|
||||
.vertical-expand-collapse-enter {
|
||||
transition: all linear 0.15s;
|
||||
transition: all linear 0.3s;
|
||||
max-height: 0;
|
||||
opacity: 0.01;
|
||||
|
||||
|
@ -24,7 +24,7 @@
|
|||
}
|
||||
|
||||
.vertical-expand-collapse-leave {
|
||||
transition: all linear 0.15s;
|
||||
transition: all linear 0.3s;
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"zombie": "^4.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/design-language": "0.0.67",
|
||||
"@jenkins-cd/design-language": "0.0.68",
|
||||
"@jenkins-cd/js-extensions": "0.0.21-beta4",
|
||||
"@jenkins-cd/js-modules": "0.0.5",
|
||||
"history": "2.0.2",
|
||||
|
|
Loading…
Reference in New Issue