From 4c6f923f5fc5a4aecc55907e76edd845448453bf Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Tue, 25 Sep 2018 19:52:59 -0400 Subject: [PATCH] Working draft, minimal styling --- .gitignore | 5 +- pom.xml | 5 + .../idrbind/controllers/JobController.java | 123 +++++++++ .../idrbind/controllers/MainController.java | 47 ++++ .../jacobsonmt/idrbind/model/IDRBindJob.java | 194 ++++++++++++++ .../idrbind/model/IDRBindJobResult.java | 15 ++ .../idrbind/model/PurgeOldJobs.java | 29 ++ .../jacobsonmt/idrbind/rest/JobEndpoint.java | 79 ++++++ .../idrbind/services/EmailService.java | 96 +++++++ .../idrbind/services/JobManager.java | 253 ++++++++++++++++++ .../idrbind/settings/ApplicationSettings.java | 28 ++ .../jacobsonmt/idrbind/settings/Messages.java | 27 ++ .../idrbind/settings/SiteSettings.java | 28 ++ src/main/resources/application.properties | 66 +++++ .../static/img/UBC-logo-signature-blue.gif | Bin 0 -> 7063 bytes src/main/resources/static/js/queue.js | 8 + src/main/resources/templates/contact.html | 41 +++ .../resources/templates/documentation.html | 41 +++ src/main/resources/templates/faq.html | 41 +++ .../resources/templates/fragments/footer.html | 2 +- .../resources/templates/fragments/header.html | 5 +- .../templates/fragments/job-table.html | 37 +++ .../resources/templates/fragments/title.html | 5 +- src/main/resources/templates/index.html | 28 +- src/main/resources/templates/job.html | 84 ++++++ src/main/resources/templates/queue.html | 49 ++++ 26 files changed, 1327 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/services/EmailService.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/services/JobManager.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/Messages.java create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java create mode 100644 src/main/resources/static/img/UBC-logo-signature-blue.gif create mode 100644 src/main/resources/static/js/queue.js create mode 100644 src/main/resources/templates/contact.html create mode 100644 src/main/resources/templates/documentation.html create mode 100644 src/main/resources/templates/faq.html create mode 100644 src/main/resources/templates/fragments/job-table.html create mode 100644 src/main/resources/templates/job.html create mode 100644 src/main/resources/templates/queue.html diff --git a/.gitignore b/.gitignore index 82eca33..aaf55ff 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ /nbbuild/ /dist/ /nbdist/ -/.nb-gradle/ \ No newline at end of file +/.nb-gradle/ + +/application.properties + diff --git a/pom.xml b/pom.xml index 065916b..44bdabe 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-configuration-processor + true + diff --git a/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java b/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java new file mode 100644 index 0000000..094237c --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java @@ -0,0 +1,123 @@ +package com.jacobsonmt.idrbind.controllers; + +import com.jacobsonmt.idrbind.model.IDRBindJob; +import com.jacobsonmt.idrbind.model.IDRBindJobResult; +import com.jacobsonmt.idrbind.services.JobManager; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Log4j2 +@Controller +public class JobController { + + @Autowired + private JobManager jobManager; + + + + @PostMapping("/") + public String handleFileUpload(@RequestParam("pdbFile") MultipartFile pdbFile, + @RequestParam("sequence") MultipartFile sequence, + @RequestParam("label") String label, + @RequestParam(value = "email", required = false, defaultValue = "") String email, + @RequestParam(value = "hidden", required = false, defaultValue = "false") boolean hidden, + HttpServletRequest request, + RedirectAttributes redirectAttributes) throws IOException { + + String ipAddress = request.getHeader( "X-FORWARDED-FOR" ); + if ( ipAddress == null ) { + ipAddress = request.getRemoteAddr(); + } + + IDRBindJob job = jobManager.createJob( ipAddress, + label, + IDRBindJob.inputStreamToString( pdbFile.getInputStream() ), + IDRBindJob.inputStreamToString( sequence.getInputStream() ), + email, + hidden ); + jobManager.submit( job ); + + redirectAttributes.addFlashAttribute("message", + "Job Submitted! View job here."); + + return "redirect:/"; + } + + @GetMapping("/job/{jobId}") + public String job( @PathVariable("jobId") String jobId, + Model model) throws IOException { + + IDRBindJob job = jobManager.getSavedJob( jobId ); + + if (job==null) { + return "/"; + } + + model.addAttribute("job", jobManager.getSavedJob( jobId ).toValueObject( true ) ); + + return "job"; + } + + @GetMapping("/job/{jobId}/resultPDB") + public ResponseEntity jobResultPDB( @PathVariable("jobId") String jobId) { + IDRBindJob job = jobManager.getSavedJob( jobId ); + + // test for not null and complete + if ( job != null && job.isComplete() && !job.isFailed() ) { + try { + IDRBindJobResult result = job.getFuture().get( 1, TimeUnit.SECONDS ); + + return ResponseEntity.ok() + .contentType( MediaType.parseMediaType("application/octet-stream")) + .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + job.getLabel() + "-result.pdb\"") + .body(result.getResultPDB()); + + } catch ( InterruptedException | ExecutionException | TimeoutException e ) { + log.warn( e ); + ResponseEntity.status( 500 ).body( "" ); + } + } + return ResponseEntity.badRequest().body( "" ); + } + + @GetMapping("/job/{jobId}/resultCSV") + public ResponseEntity jobResultCSV( @PathVariable("jobId") String jobId) { + IDRBindJob job = jobManager.getSavedJob( jobId ); + + // test for not null and complete + if ( job != null && job.isComplete() && !job.isFailed() ) { + try { + IDRBindJobResult result = job.getFuture().get( 1, TimeUnit.SECONDS ); + + return ResponseEntity.ok() + .contentType( MediaType.parseMediaType("application/octet-stream")) + .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + job.getLabel() + "-result.csv\"") + .body(result.getResultCSV()); + + } catch ( InterruptedException | ExecutionException | TimeoutException e ) { + log.warn( e ); + ResponseEntity.status( 500 ).body( "" ); + } + } + return ResponseEntity.badRequest().body( "" ); + } + + +} diff --git a/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java b/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java new file mode 100644 index 0000000..48738bb --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java @@ -0,0 +1,47 @@ +package com.jacobsonmt.idrbind.controllers; + +import com.jacobsonmt.idrbind.services.JobManager; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.io.IOException; + +@Log4j2 +@Controller +public class MainController { + + @Autowired + private JobManager jobManager; + + @GetMapping("/") + public String index( Model model) throws IOException { + + return "index"; + } + + @GetMapping("/queue") + public String queue( Model model) throws IOException { + + model.addAttribute("jobs", jobManager.listPublicJobs() ); + + return "queue"; + } + + @GetMapping("/documentation") + public String documentation( Model model) throws IOException { + return "documentation"; + } + + @GetMapping("/faq") + public String faq( Model model) throws IOException { + return "faq"; + } + + @GetMapping("/contact") + public String contact( Model model) throws IOException { + return "contact"; + } +} diff --git a/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java new file mode 100644 index 0000000..64ff3c1 --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java @@ -0,0 +1,194 @@ +package com.jacobsonmt.idrbind.model; + +import com.jacobsonmt.idrbind.services.JobManager; +import lombok.*; +import lombok.extern.log4j.Log4j2; +import org.springframework.util.StopWatch; + +import javax.ws.rs.core.StreamingOutput; +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.concurrent.*; + +@Log4j2 +@Getter +@Setter +@Builder +@ToString(of = {"jobId", "userId", "label", "hidden"}) +@EqualsAndHashCode(of = {"jobId"}) +public class IDRBindJob implements Callable { + + // Path to resources + private String command; + private String commandWorkingDirectory; + private String inputPDBFullPath; + private String inputProteinChainFullPath; + private String outputScoredPDBFullPath; + private String outputCSVFullPath; + + + // Information on creation of job + private String userId; + private String jobId; + private String label; + private String inputPDBContent; + private String inputProteinChainIds; + @Builder.Default private boolean hidden = true; + private Date submittedDate; + private String email; + + // Information on running / completion + @Builder.Default private boolean running = false; + @Builder.Default private boolean failed = false; + @Builder.Default private boolean complete = false; + private Integer position; + private String status; + + // Results + private Future future; + private StreamingOutput resultFile; + private long executionTime; + + // Saving Job information / results for later + @Builder.Default private boolean saved = false; + private Long saveExpiredDate; + + // Back-reference to owning JobManager + private JobManager jobManager; + + @Override + public IDRBindJobResult call() throws Exception { + + try { + + log.info( "Starting job (" + label + ") for user: (" + userId + ")" ); + + this.running = true; + this.status = "Processing"; + + jobManager.onJobStart( this ); + + // Write content to input + File pdbFile = new File( inputPDBFullPath ); + writeToFile( pdbFile, inputPDBContent ); + + File chainFile = new File( inputProteinChainFullPath ); + writeToFile( chainFile, inputProteinChainIds ); + + // Execute script + StopWatch sw = new StopWatch(); + sw.start(); + executeCommand( "./" + command, commandWorkingDirectory ); + sw.stop(); + this.executionTime = sw.getTotalTimeMillis() / 1000; + + // Get output + String resultPDB = inputStreamToString( new FileInputStream( outputScoredPDBFullPath ) ); + String resultCSV = inputStreamToString( new FileInputStream( outputCSVFullPath ) ); + + log.info( "Finished job (" + label + ") for user: (" + userId + ")" ); + this.running = false; + this.complete = true; + + jobManager.onJobComplete( this ); + jobManager = null; + + return new IDRBindJobResult( resultPDB, resultCSV ); + } catch ( Exception e ) { + log.error( e ); + this.complete = true; + this.running = false; + this.failed = true; + jobManager = null; + this.status = "Failed"; + return new IDRBindJobResult( "", "" ); + } + + } + + private static void writeToFile(File file, String fileContents) throws IOException { + + try (FileOutputStream fop = new FileOutputStream( file )) { + + // if file doesn't exists, then create it + if (!file.exists()) { + file.createNewFile(); + } + + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fop)); + writer.write(fileContents); + writer.newLine(); + writer.flush(); + writer.close(); + + } + + } + + private static String executeCommand( String command, String path ) { + + StringBuffer output = new StringBuffer(); + + Process p; + try { + p = Runtime.getRuntime().exec( command, null, new File( path ) ); + p.waitFor(); + BufferedReader reader = new BufferedReader( new InputStreamReader( p.getInputStream() ) ); + + String line = ""; + while ( ( line = reader.readLine() ) != null ) { + output.append( line + "\r\n" ); + } + + } catch ( Exception e ) { + e.printStackTrace(); + } + + return output.toString(); + } + + public static String inputStreamToString(InputStream inputStream) throws IOException { + StringBuilder textBuilder = new StringBuilder(); + try (Reader reader = new BufferedReader(new InputStreamReader + (inputStream, Charset.forName(StandardCharsets.UTF_8.name())))) { + int c = 0; + while ((c = reader.read()) != -1) { + textBuilder.append((char) c); + } + } + return textBuilder.toString(); + } + + @Getter + @AllArgsConstructor + public static final class IDRBindJobVO { + private final String jobId; + private final String label; + private final String status; + private final boolean running; + private final boolean failed; + private final boolean complete; + private Integer position; + private final String email; + private final boolean hidden; + private final Date submitted; + private final IDRBindJobResult result; + } + + public IDRBindJobVO toValueObject(boolean obfuscateEmail) { + + IDRBindJobResult result = null; + if ( this.isComplete() ) { + try { + result = this.getFuture().get( 1, TimeUnit.SECONDS ); + } catch ( InterruptedException | ExecutionException | TimeoutException e ) { + log.error( e ); + } + } + + return new IDRBindJobVO( jobId, label, status, running, failed, complete, position, obfuscateEmail ? email.replaceAll("(\\w{0,3})(\\w+.*)(@.*)", "$1****$3") : email, hidden, submittedDate, result ); + } + +} diff --git a/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java new file mode 100644 index 0000000..b3efb1f --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java @@ -0,0 +1,15 @@ +package com.jacobsonmt.idrbind.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public final class IDRBindJobResult { + + private final String resultPDB; + private final String resultCSV; + +} diff --git a/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java b/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java new file mode 100644 index 0000000..5954a6e --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java @@ -0,0 +1,29 @@ +package com.jacobsonmt.idrbind.model; + +import java.util.Iterator; +import java.util.Map; + +public class PurgeOldJobs implements Runnable { + + private Map savedJobs; + + public PurgeOldJobs( Map savedJobs ) { + this.savedJobs = savedJobs; + } + + @Override + public void run() { + synchronized ( savedJobs ) { + for ( Iterator> it = savedJobs.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + IDRBindJob job = entry.getValue(); + if ( job.isComplete() && System.currentTimeMillis() > job.getSaveExpiredDate() ) { + job.setSaved( false ); + job.setSaveExpiredDate( null ); + it.remove(); + } + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java b/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java new file mode 100644 index 0000000..2daa5eb --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java @@ -0,0 +1,79 @@ +package com.jacobsonmt.idrbind.rest; + +import com.jacobsonmt.idrbind.model.IDRBindJob; +import com.jacobsonmt.idrbind.services.JobManager; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; + +@Log4j2 +@RequestMapping("/api") +@RestController +public class JobEndpoint { + + @Autowired + private JobManager jobManager; + + @RequestMapping(value = "/job/{jobId}", method = RequestMethod.GET, produces = {MediaType.APPLICATION_JSON_VALUE}) + public IDRBindJob.IDRBindJobVO getJob(@PathVariable String jobId) { + return createJobValueObject( jobManager.getSavedJob( jobId ) ); + } + + @RequestMapping(value = "/job", method = RequestMethod.GET, produces = {MediaType.APPLICATION_JSON_VALUE}) + public IDRBindJob.IDRBindJobVO getJob2(@RequestParam(value = "jobId") String jobId) { + return createJobValueObject( jobManager.getSavedJob( jobId ) ); + } + + @RequestMapping(value = "/job/{jobId}/status", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE}) + public String getJobStatus(@PathVariable String jobId) { + IDRBindJob job = jobManager.getSavedJob( jobId ); + if ( job != null ) { + return job.getStatus(); + } + + log.info( "Job Not Found" ); + return "Job Not Found"; + } + + @RequestMapping(value = "/job/status", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE}) + public String getJobStatus2(@RequestParam(value = "jobId") String jobId) { + IDRBindJob job = jobManager.getSavedJob( jobId ); + if ( job != null ) { + return job.getStatus(); + } + + log.info( "Job Not Found" ); + return "Job Not Found"; + } + + @RequestMapping(value = "/submitJob", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE}) + public String submitJob(@RequestParam(value = "label") String label, + @RequestParam(value = "pdbContent") String pdbContent, + @RequestParam(value = "proteinChain") String proteinChain, + @RequestParam(value = "email", required = false, defaultValue = "") String email, + @RequestParam(value = "hidden", required = false, defaultValue = "false") boolean hidden, + HttpServletRequest request + ) { + String ipAddress = request.getHeader( "X-FORWARDED-FOR" ); + if ( ipAddress == null ) { + ipAddress = request.getRemoteAddr(); + } + + IDRBindJob job = jobManager.createJob( label, label, pdbContent, proteinChain, email, hidden ); + jobManager.submit( job ); + log.info( "Job Submitted: " + job.getJobId() ); + return "Job Submitted: " + job.getJobId(); + } + + private IDRBindJob.IDRBindJobVO createJobValueObject( IDRBindJob job) { + if ( job == null ) { + return null; + } + return job.toValueObject(true); + } + + +} diff --git a/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java b/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java new file mode 100644 index 0000000..d5d6fdd --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java @@ -0,0 +1,96 @@ +package com.jacobsonmt.idrbind.services; + +import com.jacobsonmt.idrbind.model.IDRBindJob; +import com.jacobsonmt.idrbind.settings.SiteSettings; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import javax.servlet.http.HttpServletRequest; + +@Service +public class EmailService { + + @Autowired + private JavaMailSender emailSender; + + @Autowired + SiteSettings siteSettings; + + private void sendMessage( String subject, String content, String to ) throws MessagingException { + sendMessage( subject, content, to, null ); + } + + private void sendMessage( String subject, String content, String to, MultipartFile attachment ) throws MessagingException { + + MimeMessage message = emailSender.createMimeMessage(); + + MimeMessageHelper helper = new MimeMessageHelper( message, true ); + + helper.setSubject( subject ); + helper.setText( content, true ); + helper.setTo( to ); + helper.setFrom( siteSettings.getAdminEmail() ); + + if ( attachment != null ) { + helper.addAttachment( attachment.getOriginalFilename(), attachment ); + } + + emailSender.send( message ); + + } + + public void sendSupportMessage( String message, String name, String email, HttpServletRequest request, + MultipartFile attachment ) throws MessagingException { + String content = + "Name: " + name + "\r\n" + + "Email: " + email + "\r\n" + + "User-Agent: " + request.getHeader( "User-Agent" ) + "\r\n" + + "Message: " + message + "\r\n" + + "File Attached: " + String.valueOf( attachment != null && !attachment.getOriginalFilename().equals( "" ) ); + + sendMessage( "IDR Bind Help - Contact Support", content, siteSettings.getAdminEmail(), attachment ); + } + + public void sendJobStartMessage( IDRBindJob job ) throws MessagingException { + if ( job.getEmail() == null || job.getEmail().isEmpty() ) { + return; + } + StringBuilder content = new StringBuilder(); + content.append( "

Job Submitted

" ); + content.append( "

Label: " + job.getLabel() + "

" ); + content.append( "

Submitted: " + job.getSubmittedDate() + "

" ); + content.append( "

Status: " + job.getStatus() + "

" ); + if ( job.isSaved() ) { + content.append( "

Saved Link: " + "" + + siteSettings.getFullUrl() + "job/" + job.getJobId() + "'

" ); + } + + sendMessage( "IDB Bind - Job Submitted", content.toString(), job.getEmail() ); + } + + public void sendJobCompletionMessage( IDRBindJob job ) throws MessagingException { + if ( job.getEmail() == null || job.getEmail().isEmpty() ) { + return; + } + StringBuilder content = new StringBuilder(); + content.append( "

Job Complete

" ); + content.append( "

Label: " + job.getLabel() + "

" ); + content.append( "

Submitted: " + job.getSubmittedDate() + "

" ); + content.append( "

Status: " + job.getStatus() + "

" ); + if ( job.isSaved() ) { + content.append( "

Saved Link: " + "" + + siteSettings.getFullUrl() + "job/" + job.getJobId() + "'

" ); + } + + sendMessage( "IDB Bind - Job Complete", content.toString(), job.getEmail() ); + } + +} \ No newline at end of file diff --git a/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java b/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java new file mode 100644 index 0000000..3edeb46 --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java @@ -0,0 +1,253 @@ +package com.jacobsonmt.idrbind.services; + +import com.jacobsonmt.idrbind.model.IDRBindJob; +import com.jacobsonmt.idrbind.model.IDRBindJobResult; +import com.jacobsonmt.idrbind.model.PurgeOldJobs; +import com.jacobsonmt.idrbind.settings.ApplicationSettings; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.mail.MessagingException; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Log4j2 +@Service +public class JobManager { + + @Autowired + ApplicationSettings applicationSettings; + + @Autowired + EmailService emailService; + + // Main executor to process jobs + private ExecutorService executor; + + // Contains a copy of the processing queue of jobs internal to executor. + // It is non-trivial to extract a list of running/waiting jobs in the executor + // so we maintain a copy in sync with the real thing. + private List jobQueueMirror = new LinkedList<>(); + + // Secondary user queues or waiting lines. One specific to each user/session. + private Map> userQueues = new ConcurrentHashMap<>(); + + // Contains map of token to saved job for future viewing + private Map savedJobs = new ConcurrentHashMap<>(); + + // Used to periodically purge the old saved jobs + private ScheduledExecutorService scheduler; + + @PostConstruct + private void initialize() { + executor = Executors.newSingleThreadExecutor(); + if ( applicationSettings.isPurgeSavedJobs() ) { + scheduler = Executors.newSingleThreadScheduledExecutor(); + // Checks every hour for old jobs + scheduler.scheduleAtFixedRate( new PurgeOldJobs( savedJobs ), 0, + applicationSettings.getPurgeSavedJobsTimeHours(), TimeUnit.HOURS ); + } + + } + + @PreDestroy + public void destroy() { + log.info( "JobManager destroyed" ); + executor.shutdownNow(); + scheduler.shutdownNow(); + } + + public IDRBindJob createJob( String userId, + String label, + String inputPDBContent, + String inputProteinChainIds, + String email, + boolean hidden ) { + IDRBindJob.IDRBindJobBuilder jobBuilder = IDRBindJob.builder(); + + // Static Resources + jobBuilder.command( applicationSettings.getCommand() ); + jobBuilder.commandWorkingDirectory( applicationSettings.getCommandWorkingDirectory() ); + jobBuilder.inputPDBFullPath( applicationSettings.getInputPDBPath() ); + jobBuilder.inputProteinChainFullPath( applicationSettings.getInputChainPath() ); + jobBuilder.outputScoredPDBFullPath( applicationSettings.getOutputScoredPDBPath() ); + jobBuilder.outputCSVFullPath( applicationSettings.getOutputCSVPath() ); + + // Generated + jobBuilder.jobId( UUID.randomUUID().toString() ); + + // User Inputs + jobBuilder.userId( userId ); + jobBuilder.label( label ); + jobBuilder.inputPDBContent( inputPDBContent ); + jobBuilder.inputProteinChainIds( inputProteinChainIds ); + jobBuilder.hidden( hidden ); + jobBuilder.email( email ); + + IDRBindJob job = jobBuilder.build(); + + boolean validation = validateJob( job ); + + if ( !validation ) { + job.setComplete( true ); + job.setFailed( true ); + job.setStatus( "Failed" ); + } + + return job; + + } + + private void submitToProcessQueue( IDRBindJob job ) { + synchronized ( jobQueueMirror ) { + log.info( "Submitting job (" + job.getJobId() + ") for user: (" + job.getUserId() + ") to process queue" ); + job.setJobManager( this ); + Future future = executor.submit( job ); + job.setFuture( future ); + jobQueueMirror.add( job ); + job.setStatus( "Position: " + Integer.toString( jobQueueMirror.size() ) ); + job.setPosition( jobQueueMirror.size() ); + } + } + + private void submitToUserQueue( IDRBindJob job ) { + log.info( "Submitting job (" + job.getJobId() + ") for user: (" + job.getUserId() + ") to user queue" ); + + Queue jobs = userQueues.computeIfAbsent( job.getUserId(), k -> new LinkedList<>() ); + + if ( jobs.size() > applicationSettings.getUserJobLimit() ) { + log.info( "Too many jobs (" + job.getJobId() + ") for user: (" + job.getUserId() + ")"); + return; + } + + synchronized ( jobs ) { + + if ( !jobs.contains( job ) ) { + jobs.add( job ); + job.setStatus( "Pending" ); + saveJob( job ); + submitJobFromUserQueue( job.getUserId() ); + } + } + } + + private void submitJobFromUserQueue( String userId ) { + int cnt = 0; + synchronized ( jobQueueMirror ) { + + for ( IDRBindJob job : jobQueueMirror ) { + if ( job.getUserId().equals( userId ) ) cnt++; + } + } + + if ( cnt < applicationSettings.getUserProcessLimit() ) { + + Queue jobs = userQueues.get( userId ); + + if ( jobs != null ) { + IDRBindJob job; + synchronized ( jobs ) { + job = jobs.poll(); + } + if ( job != null ) { + job.setSubmittedDate( new Date() ); + submitToProcessQueue( job ); + } + } + } + + } + + private void updatePositions( String userId ) { + synchronized ( jobQueueMirror ) { + int idx = 1; + + for ( Iterator iterator = jobQueueMirror.iterator(); iterator.hasNext(); ) { + IDRBindJob job = iterator.next(); + + if ( job.isRunning() ) { + job.setStatus( "Processing" ); + idx++; + } else if ( job.isComplete() ) { + job.setStatus( "Completed in " + job.getExecutionTime() + "s" ); + job.setPosition( null ); + iterator.remove(); + } else { + job.setStatus( "Position: " + Integer.toString( idx ) ); + job.setPosition( idx ); + idx++; + } + } + } + } + + public void submit( IDRBindJob job ) { + submitToUserQueue( job ); + } + + public IDRBindJob getSavedJob( String jobId ) { + IDRBindJob job = savedJobs.get( jobId ); + if ( job !=null ) { + // Reset purge datetime + job.setSaveExpiredDate( System.currentTimeMillis() + applicationSettings.getPurgeAfterHours() * 60 * 60 * 1000 ); + } + return job; + } + + private String saveJob( IDRBindJob job ) { + synchronized ( savedJobs ) { + job.setSaved( true ); + savedJobs.put( job.getJobId(), job ); + return job.getJobId(); + } + } + + private boolean validateJob( IDRBindJob job ) { + return true; + } + + public void onJobStart( IDRBindJob job ) { + if ( applicationSettings.isEmailOnJobStart() && job.getEmail() != null && !job.getEmail().isEmpty() ) { + try { + emailService.sendJobStartMessage( job ); + } catch ( MessagingException e ) { + log.error( e ); + } + } + } + + public void onJobComplete( IDRBindJob job ) { + job.setSaveExpiredDate( System.currentTimeMillis() + applicationSettings.getPurgeAfterHours() * 60 * 60 * 1000 ); + updatePositions( job.getUserId() ); + if ( job.getEmail() != null && !job.getEmail().isEmpty() ) { + try { + emailService.sendJobCompletionMessage( job ); + } catch ( MessagingException e ) { + log.error( e ); + } + } + // Add new job for given session + submitJobFromUserQueue( job.getUserId() ); + log.info( String.format( "Jobs in queue: %d", jobQueueMirror.size() ) ); + } + + public List listPublicJobs() { + return Stream.concat(jobQueueMirror.stream(), savedJobs.values().stream()) + .distinct() + .filter( j -> !j.isHidden() ) + .map( j -> j.toValueObject( true ) ) + .sorted( + Comparator.comparing(IDRBindJob.IDRBindJobVO::getPosition, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(IDRBindJob.IDRBindJobVO::getSubmitted, Comparator.nullsLast(Date::compareTo).reversed()) + .thenComparing(IDRBindJob.IDRBindJobVO::getStatus, String::compareToIgnoreCase) + ) + .collect( Collectors.toList() ); + } + + +} diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java b/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java new file mode 100644 index 0000000..52d1685 --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java @@ -0,0 +1,28 @@ +package com.jacobsonmt.idrbind.settings; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "idrbind.settings") +@Getter +@Setter +public class ApplicationSettings { + + private String command; + private String commandWorkingDirectory; + private String inputPDBPath; + private String inputChainPath; + private String outputScoredPDBPath; + private String outputCSVPath; + private int concurrentJobs = 1; + private int userProcessLimit = 2; + private int userJobLimit = 200; + private boolean purgeSavedJobs = true; + private int purgeSavedJobsTimeHours = 1; + private int purgeAfterHours = 24; + private boolean emailOnJobStart = true; + +} \ No newline at end of file diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java b/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java new file mode 100644 index 0000000..128d1c8 --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java @@ -0,0 +1,27 @@ +package com.jacobsonmt.idrbind.settings; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "idrbind.messages") +@Getter +@Setter +public class Messages { + + @Getter + @Setter + public static class EmailMessages { + private String submit; + private String complete; + private String fail; + } + + private EmailMessages email; + + + private String title; + +} \ No newline at end of file diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java b/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java new file mode 100644 index 0000000..f52c90c --- /dev/null +++ b/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java @@ -0,0 +1,28 @@ +package com.jacobsonmt.idrbind.settings; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import javax.validation.constraints.Email; + +@Component +@ConfigurationProperties(prefix = "idrbind.site") +@Getter +@Setter +public class SiteSettings { + + private String host; + private String context; + + @Email + private String contactEmail; + @Email + private String adminEmail; + + public String getFullUrl() { + return host + context + (context.endsWith( "/" ) ? "" : "/"); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e69de29..19ac57f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -0,0 +1,66 @@ +# ============================================================== +# = Spring Email +# ============================================================== +spring.mail.default-encoding=UTF-8 +spring.mail.host=localhost +spring.mail.username=XXXXXX +spring.mail.password= +#spring.mail.port=587 +spring.mail.protocol=smtp +spring.mail.test-connection=false +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# ============================================================== +# = Application Specific Defaults +# ============================================================== + +# Job command name +idrbind.settings.command=test.sh +idrbind.settings.command-working-directory=/home/test/idr/bin/ +idrbind.settings.input-pdb-path=/home/test/idr/input/input.pdb +idrbind.settings.input-chain-path=/home/test/idr/input/chain.txt +idrbind.settings.output-scored-pdb-path=/home/test/idr/output/scored-pdb.txt +idrbind.settings.output-csv-path=/home/test/idr/output/output.csv + +# Number of jobs to process at once +idrbind.settings.concurrent-jobs=1 + +# Maximum number of jobs a user can have in the processing queue +idrbind.settings.user-process-limit=2 + +# Maxmimum number of jobs a user can have in total (processing+user queue) +idrbind.settings.user-job-limit=200 + +# Periodically destroy old saved jobs? +idrbind.settings.purge-saved-jobs=true +# Check for old jobs this often in hours +idrbind.settings.purge-saved-jobs-time-hours=1 +# After how many hours of inactivity is a job considered ready to purge? +idrbind.settings.purge-after-hours=24 + +# Send email on job start +idrbind.settings.email-on-job-start=true + +### Domain & URL Configuration ### +idrbind.site.host=http://www.idrbind.ca +idrbind.site.context= + +### Emails ### +idrbind.site.contact-email=idrbind-help@gmail.ca +idrbind.site.admin-email=JacobsonMT@gmail.com + +### Google Analytics ### +ga.tracker= +ga.domain= + +# ============================================================== +# = Custom Messages +# ============================================================== + +idrbind.messages.email.submit=Job Submitted +idrbind.messages.email.complete=Job Completed +idrbind.messages.email.fail=Job Failed + + +idrbind.messages.title=IDR Bind diff --git a/src/main/resources/static/img/UBC-logo-signature-blue.gif b/src/main/resources/static/img/UBC-logo-signature-blue.gif new file mode 100644 index 0000000000000000000000000000000000000000..be7e22b44b2d8ca9648e2ae35bc23091dd49f3be GIT binary patch literal 7063 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 + + + + IDR Bind - Job Queue + + + + + + + + + + + + +
...
+
+ + +
...
+ +
+ Contact Us +
+ + + + +
+
...
+ + \ No newline at end of file diff --git a/src/main/resources/templates/documentation.html b/src/main/resources/templates/documentation.html new file mode 100644 index 0000000..4eed2ce --- /dev/null +++ b/src/main/resources/templates/documentation.html @@ -0,0 +1,41 @@ + + + + + IDR Bind - Job Queue + + + + + + + + + + + + +
...
+
+ + +
...
+ +
+ Documentation +
+ + + + +
+
...
+ + \ No newline at end of file diff --git a/src/main/resources/templates/faq.html b/src/main/resources/templates/faq.html new file mode 100644 index 0000000..9e20617 --- /dev/null +++ b/src/main/resources/templates/faq.html @@ -0,0 +1,41 @@ + + + + + IDR Bind - Job Queue + + + + + + + + + + + + +
...
+
+ + +
...
+ +
+ FAQ +
+ + + + +
+
...
+ + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html index 2b43179..3ba25e8 100644 --- a/src/main/resources/templates/fragments/footer.html +++ b/src/main/resources/templates/fragments/footer.html @@ -5,7 +5,7 @@
diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 6bc7455..7c8ec7a 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -3,6 +3,9 @@ xmlns:th="http://www.thymeleaf.org">