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