View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.adminui.endpoint;
22  
23  import static org.opencastproject.index.service.util.JSONUtils.mapToJsonObject;
24  import static org.opencastproject.index.service.util.JSONUtils.safeString;
25  import static org.opencastproject.util.DateTimeSupport.toUTC;
26  
27  import org.opencastproject.adminui.exception.JobEndpointException;
28  import org.opencastproject.index.service.resources.list.query.JobsListQuery;
29  import org.opencastproject.index.service.util.RestUtils;
30  import org.opencastproject.job.api.Incident;
31  import org.opencastproject.job.api.IncidentTree;
32  import org.opencastproject.job.api.Job;
33  import org.opencastproject.security.api.UserDirectoryService;
34  import org.opencastproject.serviceregistry.api.HostRegistration;
35  import org.opencastproject.serviceregistry.api.IncidentL10n;
36  import org.opencastproject.serviceregistry.api.IncidentService;
37  import org.opencastproject.serviceregistry.api.IncidentServiceException;
38  import org.opencastproject.serviceregistry.api.ServiceRegistry;
39  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
40  import org.opencastproject.util.DateTimeSupport;
41  import org.opencastproject.util.NotFoundException;
42  import org.opencastproject.util.RestUtil;
43  import org.opencastproject.util.SmartIterator;
44  import org.opencastproject.util.data.Tuple;
45  import org.opencastproject.util.doc.rest.RestParameter;
46  import org.opencastproject.util.doc.rest.RestQuery;
47  import org.opencastproject.util.doc.rest.RestResponse;
48  import org.opencastproject.util.doc.rest.RestService;
49  import org.opencastproject.util.requests.SortCriterion;
50  import org.opencastproject.util.requests.SortCriterion.Order;
51  import org.opencastproject.workflow.api.WorkflowService;
52  
53  import com.google.gson.JsonArray;
54  import com.google.gson.JsonObject;
55  
56  import org.apache.commons.lang3.StringUtils;
57  import org.osgi.framework.BundleContext;
58  import org.osgi.service.component.annotations.Activate;
59  import org.osgi.service.component.annotations.Component;
60  import org.osgi.service.component.annotations.Reference;
61  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import java.util.ArrayList;
66  import java.util.Collections;
67  import java.util.Comparator;
68  import java.util.List;
69  import java.util.Locale;
70  import java.util.Optional;
71  
72  import javax.servlet.http.HttpServletResponse;
73  import javax.ws.rs.GET;
74  import javax.ws.rs.Path;
75  import javax.ws.rs.Produces;
76  import javax.ws.rs.QueryParam;
77  import javax.ws.rs.WebApplicationException;
78  import javax.ws.rs.core.MediaType;
79  import javax.ws.rs.core.Response;
80  
81  @Path("/admin-ng/job")
82  @RestService(
83      name = "JobProxyService",
84      title = "UI Jobs",
85      abstractText = "This service provides the job data for the UI.",
86      notes = { "These Endpoints deliver informations about the job required for the UI.",
87                "<strong>Important:</strong> "
88                  + "<em>This service is for exclusive use by the module admin-ui. Its API might change "
89                  + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
90                  + "DO NOT use this for integration of third-party applications.<em>"})
91  @Component(
92      immediate = true,
93      service = JobEndpoint.class,
94      property = {
95          "service.description=Admin UI - Job facade Endpoint",
96          "opencast.service.type=org.opencastproject.adminui.endpoint.JobEndpoint",
97          "opencast.service.path=/admin-ng/job"
98      }
99  )
100 @JaxrsResource
101 public class JobEndpoint {
102 
103   private static final Logger logger = LoggerFactory.getLogger(JobEndpoint.class);
104   public static final Response NOT_FOUND = Response.status(Response.Status.NOT_FOUND).build();
105 
106   private enum JobSort {
107     CREATOR, OPERATION, PROCESSINGHOST, PROCESSINGNODE, STATUS, STARTED, SUBMITTED, TYPE, ID
108   }
109 
110   private static final String NEGATE_PREFIX = "-";
111   private static final String WORKFLOW_STATUS_TRANSLATION_PREFIX = "EVENTS.EVENTS.DETAILS.WORKFLOWS.OPERATION_STATUS.";
112   private static final String JOB_STATUS_TRANSLATION_PREFIX = "SYSTEMS.JOBS.STATUS.";
113 
114   private WorkflowService workflowService;
115   private ServiceRegistry serviceRegistry;
116   private IncidentService incidentService;
117   private UserDirectoryService userDirectoryService;
118 
119   /** OSGi callback for the workflow service. */
120   @Reference
121   public void setWorkflowService(WorkflowService workflowService) {
122     this.workflowService = workflowService;
123   }
124 
125   /** OSGi callback for the service registry. */
126   @Reference
127   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
128     this.serviceRegistry = serviceRegistry;
129   }
130 
131   /** OSGi callback for the incident service. */
132   @Reference
133   public void setIncidentService(IncidentService incidentService) {
134     this.incidentService = incidentService;
135   }
136 
137   @Reference
138   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
139     this.userDirectoryService = userDirectoryService;
140   }
141 
142   @Activate
143   protected void activate(BundleContext bundleContext) {
144     logger.info("Activate job endpoint");
145   }
146 
147   @GET
148   @Path("jobs.json")
149   @Produces(MediaType.APPLICATION_JSON)
150   @RestQuery(
151       description = "Returns the list of active jobs",
152       name = "jobs",
153       restParameters = {
154           @RestParameter(name = "limit", description = "The maximum number of items to return per page",
155               isRequired = false, type = RestParameter.Type.INTEGER),
156           @RestParameter(name = "offset", description = "The offset",
157               isRequired = false, type = RestParameter.Type.INTEGER),
158           @RestParameter(name = "filter", description = "Filter results by hostname, status or free text query",
159               isRequired = false, type = RestParameter.Type.STRING),
160           @RestParameter(name = "sort", description = "The sort order. May include any of the following: CREATOR, "
161               + "OPERATION, PROCESSINGHOST, STATUS, STARTED, SUBMITTED or TYPE. "
162               + "The suffix must be :ASC for ascending or :DESC for descending sort order (e.g. OPERATION:DESC)",
163               isRequired = false, type = RestParameter.Type.STRING)
164       },
165       responses = {
166           @RestResponse(description = "Returns the list of active jobs from Opencast",
167               responseCode = HttpServletResponse.SC_OK)
168       },
169       returnDescription = "The list of jobs as JSON")
170   public Response getJobs(@QueryParam("limit") final int limit, @QueryParam("offset") final int offset,
171           @QueryParam("filter") final String filter, @QueryParam("sort") final String sort) {
172     JobsListQuery query = new JobsListQuery();
173     EndpointUtil.addRequestFiltersToQuery(filter, query);
174     query.setLimit(limit);
175     query.setOffset(offset);
176 
177     String fHostname = null;
178     if (query.getHostname().isPresent()) {
179       fHostname = StringUtils.trimToNull(query.getHostname().get());
180     }
181     String fNodeName = null;
182     if (query.getNodeName().isPresent()) {
183       fNodeName = StringUtils.trimToNull(query.getNodeName().get());
184     }
185     String fStatus = null;
186     if (query.getStatus().isPresent()) {
187       fStatus = StringUtils.trimToNull(query.getStatus().get());
188     }
189     String fFreeText = null;
190     if (query.getFreeText().isPresent()) {
191       fFreeText = StringUtils.trimToNull(query.getFreeText().get());
192     }
193 
194     List<JobExtended> jobs = new ArrayList<>();
195     try {
196       String vNodeName;
197       Optional<HostRegistration> server;
198       List<HostRegistration> servers = serviceRegistry.getHostRegistrations();
199       for (Job job : serviceRegistry.getActiveJobs()) {
200         // filter workflow jobs
201         if (StringUtils.equals(WorkflowService.JOB_TYPE, job.getJobType())
202                 && StringUtils.equals("START_WORKFLOW", job.getOperation())) {
203           continue;
204         }
205 
206         // filter by hostname
207         if (fHostname != null && !StringUtils.equalsIgnoreCase(job.getProcessingHost(), fHostname)) {
208           continue;
209         }
210 
211         server = findServerByHost(job.getProcessingHost(), servers);
212         vNodeName = server.isPresent() ? server.get().getNodeName() : "";
213 
214         // filter by node name
215         if (fNodeName != null && (server.isPresent()) && !StringUtils.equalsIgnoreCase(vNodeName, fNodeName)) {
216           continue;
217         }
218 
219         // filter by status
220         if (fStatus != null && !StringUtils.equalsIgnoreCase(job.getStatus().toString(), fStatus)) {
221           continue;
222         }
223 
224         // fitler by user free text
225         if (fFreeText != null
226             && !StringUtils.equalsIgnoreCase(job.getProcessingHost(), fFreeText)
227             && !StringUtils.equalsIgnoreCase(vNodeName, fFreeText)
228             && !StringUtils.equalsIgnoreCase(job.getJobType(), fFreeText)
229             && !StringUtils.equalsIgnoreCase(job.getOperation(), fFreeText)
230             && !StringUtils.equalsIgnoreCase(job.getCreator(), fFreeText)
231             && !StringUtils.equalsIgnoreCase(job.getStatus().toString(), fFreeText)
232             && !StringUtils.equalsIgnoreCase(Long.toString(job.getId()), fFreeText)
233             && (job.getRootJobId() != null && !StringUtils.equalsIgnoreCase(Long.toString(job.getRootJobId()),
234             fFreeText))) {
235           continue;
236         }
237         jobs.add(new JobExtended(job, vNodeName));
238       }
239     } catch (ServiceRegistryException ex) {
240       logger.error("Failed to retrieve jobs list from service registry.", ex);
241       return RestUtil.R.serverError();
242     }
243 
244     JobSort sortKey = JobSort.SUBMITTED;
245     boolean ascending = true;
246     if (StringUtils.isNotBlank(sort)) {
247       try {
248         SortCriterion sortCriterion = RestUtils.parseSortQueryParameter(sort).iterator().next();
249         sortKey = JobSort.valueOf(sortCriterion.getFieldName().toUpperCase());
250         ascending = Order.Ascending == sortCriterion.getOrder()
251                 || Order.None == sortCriterion.getOrder();
252       } catch (WebApplicationException ex) {
253         logger.warn("Failed to parse sort criterion \"{}\", invalid format.", sort);
254       } catch (IllegalArgumentException ex) {
255         logger.warn("Can not apply sort criterion \"{}\", no field with this name.", sort);
256       }
257     }
258 
259     JobComparator comparator = new JobComparator(sortKey, ascending);
260     Collections.sort(jobs, comparator);
261     List<JsonObject> json = getJobsAsJSON(new SmartIterator(
262             query.getLimit().orElse(0),
263             query.getOffset().orElse(0))
264             .applyLimitAndOffset(jobs));
265 
266     return RestUtils.okJsonList(json, offset, limit, jobs.size());
267   }
268 
269   /* Class to handle additional information related to a job */
270   class JobExtended {
271 
272     private final Job job;
273     private final String nodeName;
274 
275     JobExtended(Job job, String nodeName) {
276       this.job = job;
277       this.nodeName = nodeName;
278     }
279 
280     public Job getJob() {
281       return job;
282     }
283 
284     public String getNodeName() {
285       return nodeName;
286     }
287   }
288 
289   public List<JsonObject> getJobsAsJSON(List<JobExtended> jobs) {
290     List<JsonObject> jsonList = new ArrayList<>();
291     for (JobExtended jobEx : jobs) {
292       Job job = jobEx.getJob();
293 
294       JsonObject jobJson = new JsonObject();
295       jobJson.addProperty("id", job.getId());
296       jobJson.addProperty("type", job.getJobType());
297       jobJson.addProperty("operation", job.getOperation());
298       jobJson.addProperty("status", JOB_STATUS_TRANSLATION_PREFIX + job.getStatus().toString());
299       jobJson.addProperty("submitted", job.getDateCreated() != null
300           ? DateTimeSupport.toUTC(job.getDateCreated().getTime()) : "");
301       jobJson.addProperty("started", job.getDateStarted() != null
302           ? DateTimeSupport.toUTC(job.getDateStarted().getTime()) : "");
303       jobJson.addProperty("creator", safeString(job.getCreator()));
304       jobJson.addProperty("processingHost", safeString(job.getProcessingHost()));
305       jobJson.addProperty("processingNode", safeString(jobEx.getNodeName()));
306 
307       jsonList.add(jobJson);
308     }
309     return jsonList;
310   }
311 
312   /**
313    * Returns the list of incidents for a given workflow instance
314    *
315    * @param jobId
316    *          the workflow instance id
317    * @param locale
318    *          the language in which title and description shall be returned
319    * @param cascade
320    *          if true, return the incidents of the given job and those of of its descendants
321    * @return the list incidents as JSON array
322    * @throws JobEndpointException
323    * @throws NotFoundException
324    */
325 
326   public JsonArray getIncidentsAsJSON(long jobId, final Locale locale, boolean cascade)
327           throws JobEndpointException, NotFoundException {
328     final List<Incident> incidents;
329     try {
330       final IncidentTree it = incidentService.getIncidentsOfJob(jobId, cascade);
331       incidents = cascade ? flatten(it) : it.getIncidents();
332     } catch (IncidentServiceException e) {
333       throw new JobEndpointException(String.format(
334           "Not able to get the incidents for the job %d from the incident service : %s", jobId, e), e.getCause());
335     }
336 
337     JsonArray resultArray = new JsonArray();
338     for (Incident i : incidents) {
339       JsonObject incidentJson = new JsonObject();
340 
341       incidentJson.addProperty("id", i.getId());
342       incidentJson.addProperty("severity", safeString(i.getSeverity()));
343       incidentJson.addProperty("timestamp", safeString(toUTC(i.getTimestamp().getTime())));
344 
345       // Merge localized fields
346       JsonObject localized = localizeIncident(i, locale);
347       for (String key : localized.keySet()) {
348         incidentJson.add(key, localized.get(key));
349       }
350 
351       resultArray.add(incidentJson);
352     }
353 
354     return resultArray;
355   }
356 
357   /**
358    * Flatten a tree of incidents.
359    *
360    * @return a list of incidents
361    */
362   private List<Incident> flatten(IncidentTree incidentsTree) {
363     final List<Incident> incidents = new ArrayList<>();
364     incidents.addAll(incidentsTree.getIncidents());
365     for (IncidentTree descendantTree : incidentsTree.getDescendants()) {
366       incidents.addAll(flatten(descendantTree));
367     }
368     return incidents;
369   }
370 
371   /**
372    * Return localized title and description of an incident as JSON.
373    *
374    * @param incident
375    *          the incident to localize
376    * @param locale
377    *          the locale to be used to create title and description
378    * @return JSON object
379    */
380   private JsonObject localizeIncident(Incident incident, Locale locale) {
381     JsonObject localized = new JsonObject();
382 
383     try {
384       IncidentL10n loc = incidentService.getLocalization(incident.getId(), locale);
385       localized.addProperty("title", safeString(loc.getTitle()));
386       localized.addProperty("description", safeString(loc.getDescription()));
387     } catch (Exception e) {
388       localized.addProperty("title", "");
389       localized.addProperty("description", "");
390     }
391 
392     return localized;
393   }
394 
395   /**
396    * Return an incident serialized as JSON.
397    *
398    * @param id
399    *          incident id
400    * @param locale
401    *          the locale to be used to create title and description
402    * @return JSON object
403    */
404   public JsonObject getIncidentAsJSON(long id, Locale locale) throws JobEndpointException, NotFoundException {
405     final Incident incident;
406     try {
407       incident = incidentService.getIncident(id);
408     } catch (IncidentServiceException e) {
409       throw new JobEndpointException(String.format("Not able to get the incident %d: %s", id, e), e.getCause());
410     }
411 
412     Long rootJobId = null;
413     try {
414       Job job = serviceRegistry.getJob(incident.getJobId());
415       rootJobId = job.getRootJobId();
416     } catch (ServiceRegistryException e) {
417       logger.info("Could not find job \"{}\" in service registry", incident.getJobId());
418     }
419 
420     JsonObject json = new JsonObject();
421     json.addProperty("id", incident.getId());
422     json.addProperty("job_id", incident.getJobId());
423     json.addProperty("root_job_id", safeString(rootJobId));
424     json.addProperty("severity", safeString(incident.getSeverity().toString()));
425     json.addProperty("timestamp", toUTC(incident.getTimestamp().getTime()));
426     json.addProperty("processing_host", safeString(incident.getProcessingHost()));
427     json.addProperty("service_type", safeString(incident.getServiceType()));
428     json.add("technical_details", mapToJsonObject(incident.getDescriptionParameters()));
429 
430     JsonArray detailsArray = new JsonArray();
431     for (Tuple<String, String> detail : incident.getDetails()) {
432       detailsArray.add(errorDetailToJson(detail));
433     }
434     json.add("details", detailsArray);
435 
436     JsonObject localized = localizeIncident(incident, locale);
437     json.add("title", localized.get("title"));
438     json.add("description", localized.get("description"));
439 
440     return json;
441   }
442 
443   public JsonObject errorDetailToJson(Tuple<String, String> detail) {
444     JsonObject json = new JsonObject();
445     json.addProperty("name", safeString(detail.getA()));
446     json.addProperty("value", safeString(detail.getB()));
447     return json;
448   }
449 
450   private class JobComparator implements Comparator<JobExtended> {
451 
452     private JobSort sortType;
453     private boolean ascending;
454 
455     JobComparator(JobSort sortType, boolean ascending) {
456       this.sortType = sortType;
457       this.ascending = ascending;
458     }
459 
460     @Override
461     public int compare(JobExtended jobEx1, JobExtended jobEx2) {
462       int result = 0;
463       Object value1 = null;
464       Object value2 = null;
465       Job job1 = jobEx1.getJob();
466       Job job2 = jobEx2.getJob();
467       switch (sortType) {
468         case CREATOR:
469           value1 = job1.getCreator();
470           value2 = job2.getCreator();
471           break;
472         case OPERATION:
473           value1 = job1.getOperation();
474           value2 = job2.getOperation();
475           break;
476         case PROCESSINGHOST:
477           value1 = job1.getProcessingHost();
478           value2 = job2.getProcessingHost();
479           break;
480         case PROCESSINGNODE:
481           value1 = jobEx1.getNodeName();
482           value2 = jobEx2.getNodeName();
483           break;
484         case STARTED:
485           value1 = job1.getDateStarted();
486           value2 = job2.getDateStarted();
487           break;
488         case STATUS:
489           value1 = job1.getStatus();
490           value2 = job2.getStatus();
491           break;
492         case SUBMITTED:
493           value1 = job1.getDateCreated();
494           value2 = job2.getDateCreated();
495           break;
496         case TYPE:
497           value1 = job1.getJobType();
498           value2 = job2.getJobType();
499           break;
500         case ID:
501           value1 = job1.getId();
502           value2 = job2.getId();
503           break;
504         default:
505       }
506 
507       if (value1 == null) {
508         return value2 == null ? 0 : 1;
509       }
510       if (value2 == null) {
511         return -1;
512       }
513       try {
514         result = ((Comparable)value1).compareTo(value2);
515       } catch (ClassCastException ex) {
516         logger.debug("Can not compare \"{}\" with \"{}\"",
517                 value1, value2, ex);
518       }
519 
520       return ascending ? result : -1 * result;
521     }
522   }
523 
524   /**
525    * @param hostname of server to find in list
526    * @param servers list of all host registrations
527    */
528   private Optional<HostRegistration> findServerByHost(String hostname, List<HostRegistration> servers) {
529     return servers.stream().filter(o -> o.getBaseUrl().equals(hostname)).findFirst();
530   }
531 }