Merge pull request #400 from jenkinsci/feature/JENKINS-36582-favorites-realtime

Feature/jenkins 36582 favorites realtime
This commit is contained in:
Cliff Meyers 2016-08-16 07:41:14 -04:00 committed by GitHub
commit df13370eb8
11 changed files with 298 additions and 29 deletions

View File

@ -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",

View File

@ -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());
}
}

View File

@ -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",

View File

@ -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>

View File

@ -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(

View File

@ -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();

View File

@ -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
}
}

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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",