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 0000000..be7e22b Binary files /dev/null and b/src/main/resources/static/img/UBC-logo-signature-blue.gif differ diff --git a/src/main/resources/static/js/queue.js b/src/main/resources/static/js/queue.js new file mode 100644 index 0000000..15b1429 --- /dev/null +++ b/src/main/resources/static/js/queue.js @@ -0,0 +1,8 @@ +$(document).ready(function () { + $('.job-table').DataTable({ + "paging": true, + "searching": false, + "info": false, + "order": [] + }); +}); \ No newline at end of file diff --git a/src/main/resources/templates/contact.html b/src/main/resources/templates/contact.html new file mode 100644 index 0000000..01eb511 --- /dev/null +++ b/src/main/resources/templates/contact.html @@ -0,0 +1,41 @@ + + + + + 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">