azkaban-aplcache

Changes

.classpath 37(+0 -37)

.gitignore 6(+6 -0)

.project 17(+0 -17)

build.gradle 392(+392 -0)

build.xml 6(+3 -3)

ivy/.gitignore 2(+1 -1)

src/java/azkaban/webapp/servlet/velocity/triggerspage.vm 95(+0 -95)

src/java/azkaban/webapp/session/SessionCache.java 85(+0 -85)

src/less/.gitignore 1(+0 -1)

src/less/azkaban-graph.less 165(+0 -165)

src/less/base.less 160(+0 -160)

src/less/flow.less 342(+0 -342)

src/less/navbar.less 121(+0 -121)

src/less/non-responsive.less 88(+0 -88)

src/less/project.less 142(+0 -142)

src/less/tables.less 149(+0 -149)

src/tl/.gitignore 1(+0 -1)

src/tl/flowstats.tl 155(+0 -155)

src/tl/flowsummary.tl 69(+0 -69)

Details

.gitignore 6(+6 -0)

diff --git a/.gitignore b/.gitignore
index 1908b60..553b1a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,14 @@
 dist/
 build/
+.gradle/
+.settings/
+node_modules/
 .idea/
 *.iml
 *.log
 TestProcess_*
 _AzkabanTestDir_*
 reports/
+/bin
+.classpath
+.project

build.gradle 392(+392 -0)

diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..a013b46
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,392 @@
+apply plugin: 'java'
+apply plugin: 'eclipse'
+
+/**
+ * Helper that calls a command and returns the output
+ */
+def cmdCaller = { commandln ->
+    def stdout = new ByteArrayOutputStream()
+    exec {
+        commandLine commandln
+        standardOutput = stdout
+    }
+    
+    return stdout.toString().trim()
+}
+
+/**
+ * Git version name from git tag
+ */
+def getVersionName = { ->
+    return cmdCaller(['git', 'describe', '--tags', '--abbrev=0'])
+}
+
+
+version = getVersionName()
+archivesBaseName = 'azkaban'
+check.dependsOn.remove(test)
+
+repositories {
+  mavenCentral()
+  mavenLocal()
+}
+
+configurations {
+    all {
+        // We don't want the kitchen sink for dependencies. Only the ones we know we need for
+        // compile and ones we need to package.
+        transitive = false
+    }
+    compile {
+        description = 'compile classpath'
+    }
+    test {
+        extendsFrom compile
+    }
+}
+configurations.compile {
+    description = 'compile classpath'
+}
+
+dependencies {
+  compile (
+    [group: 'commons-collections', name:'commons-collections', version: '3.2.1'],
+    [group: 'commons-configuration', name:'commons-configuration', version: '1.8'],
+    [group: 'commons-dbcp', name:'commons-dbcp', version: '1.4'],
+    [group: 'commons-dbutils', name:'commons-dbutils', version: '1.5'],
+    [group: 'org.apache.commons', name:'commons-email', version: '1.2'],
+    [group: 'commons-fileupload', name:'commons-fileupload', version: '1.2.1'],
+    [group: 'commons-io', name:'commons-io', version: '2.4'],
+    [group: 'org.apache.commons', name:'commons-jexl', version: '2.1.1'],
+    [group: 'commons-lang', name:'commons-lang', version: '2.6'],
+    [group: 'commons-logging', name:'commons-logging', version: '1.1.1'],
+    [group: 'commons-pool', name:'commons-pool', version: '1.6'],
+    [group: 'com.google.guava', name:'guava', version: '13.0.1'],
+    [group: 'com.h2database', name:'h2', version: '1.3.170'],
+    [group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.2.1'],
+    [group: 'org.apache.httpcomponents', name:'httpcore', version: '4.2.1'],
+    [group: 'org.codehaus.jackson', name:'jackson-core-asl', version: '1.9.5'],
+    [group: 'org.codehaus.jackson', name:'jackson-mapper-asl',version: '1.9.5'],
+    [group: 'org.codehaus.jackson', name:'jackson-core-asl', version: '1.9.5'],
+    [group: 'org.mortbay.jetty', name:'jetty', version: '6.1.26'],
+    [group: 'org.mortbay.jetty', name:'jetty-util', version: '6.1.26'],
+    [group: 'joda-time', name:'joda-time', version: '2.0'],
+    [group: 'net.sf.jopt-simple', name:'jopt-simple', version: '4.3'],
+    [group: 'log4j', name:'log4j', version: '1.2.16'],
+    [group: 'javax.mail', name:'mail', version: '1.4.5'],
+    [group: 'mysql', name:'mysql-connector-java', version: '5.1.28'],
+    [group: 'javax.servlet', name:'servlet-api', version: '2.5'],
+    [group: 'org.slf4j', name:'slf4j-api', version: '1.6.1'],
+    [group: 'org.apache.velocity', name:'velocity', version: '1.7']
+  )
+  
+  testCompile (
+    [group: 'junit', name:'junit', version: '4.11']
+  )
+}
+
+jar {
+    baseName =  'azkaban'
+    manifest {
+      attributes(
+        'Implementation-Title': 'Azkaban', 
+        'Implementation-Version': version
+      )
+    }
+}
+
+eclipse.classpath.file {
+    // Erase the whole classpath
+    beforeMerged {
+        classpath -> classpath.entries.removeAll { entry -> true }
+    }
+    
+    // We want to make sure that if there is an entry for src, that it doesn't have any
+    // include parameters
+    whenMerged { classpath -> 
+        classpath.entries.findAll { entry -> entry.kind == 'src' }*.includes = []
+    }
+}
+
+/**
+ * Invokes a makefile target that will compile less files
+ */
+task compileLess(type:Exec) {
+    workingDir 'src/main/less'
+    commandLine 'make', '-e'
+    environment (
+      OBJ_DIR : file(new File(buildDir,'/less'))
+   )
+}
+
+/**
+ * Invokes a makefile target that will compile dust files
+ */
+task compileDust(type:Exec) {
+    workingDir 'src/main/tl'
+    commandLine 'make', '-e'
+    environment (
+      OBJ_DIR : file(new File(buildDir,'/dust'))
+   )
+}
+
+/**
+ * Copies web files to a build directory
+ */
+task web(dependsOn: ['compileLess', 'compileDust']) << {
+    println 'Copying web files'
+    copy {
+        from('src/web')
+        into('build/web') 
+    }
+
+    copy {
+        from('build/dust')
+        into('build/web/js')
+    }
+    copy {
+        from('build/less')
+        into('build/web/css')
+    }
+}
+
+/*
+ * Gets the version name from the latest Git tag
+ */
+task createVersionFile() << {
+    String gitCommitHash = cmdCaller(['git', 'rev-parse', 'HEAD']);
+    String gitRepo = cmdCaller(['git', 'config', '--get', 'remote.origin.url']);
+    def date = new Date()
+    def formattedDate = date.format('yyyy-MM-dd hh:mm zzz')
+
+    String versionStr = version + '\n' +
+                        gitCommitHash + '\n' +
+                        gitRepo + '\n' +
+                        formattedDate + '\n'
+
+    File versionFile = file('build/package/version.file')
+    versionFile.parentFile.mkdirs()
+    versionFile.write(versionStr)
+}
+
+/**
+ * Packages the SoloServer version of Azkaban
+ */
+task packageSolo(type: Tar, dependsOn: [jar, 'web', 'createVersionFile']) {
+    appendix = 'solo-server'
+    packageDir = 'build/package/' + baseName + '-' + appendix
+
+    println 'Creating Azkaban Solo Server Package into ' + packageDir
+    mkdir packageDir
+    mkdir packageDir + '/extlib'
+    mkdir packageDir + '/plugins'
+    
+    println 'Copying Soloserver bin & conf'
+    copy {
+        from('src/package/soloserver')
+        into(packageDir)
+    }
+    
+    println 'Copying Azkaban lib'
+    copy {
+        from('build/libs')
+        into(packageDir + '/lib')
+    }
+    
+    println 'Copying web'
+    copy {
+        from('build/web')
+        into(packageDir + '/web')
+    }
+
+    println 'Copying sql'
+    copy {
+        from('src/sql')
+        into(packageDir + '/sql')
+    }
+
+    println 'Copying dependency jars'
+    copy {
+        into packageDir + '/lib'
+        from configurations.compile
+    }
+    
+    copy {
+        into packageDir
+        from 'build/package/version.file'
+    }
+    
+    println 'Tarballing Solo Package'
+    extension = 'tar.gz'
+    compression = Compression.GZIP
+  
+    basedir = baseName + '-' + appendix + '-' + version
+    println 'Source is in ' + packageDir
+    into(basedir) { 
+        from packageDir
+        exclude 'bin'
+    }
+    
+    dst_bin = basedir + '/bin'
+    src_bin = packageDir + '/bin'
+    from(src_bin) { 
+        into dst_bin
+        fileMode = 0755
+    }
+} 
+
+/**
+ * Packages the Sql Scripts for Azkaban DB
+ */
+task packageSql(type: Tar) {
+    String packageDir = 'build/package/sql'
+ 
+    println 'Creating Azkaban SQL Scripts into ' + packageDir
+    mkdir packageDir
+    
+    println 'Copying SQL files'
+    copy {
+        from('src/sql')
+        into(packageDir)
+    }
+    
+    String destFile = packageDir + '/create-all-sql-' + version + '.sql';
+    println('Concating create scripts to ' + destFile)
+    ant.concat(destfile:destFile, fixlastline:'yes') {
+        fileset(dir: 'src/sql') {
+            exclude(name: 'update.*.sql')
+            exclude(name: 'database.properties')
+        }
+    }
+    
+    println 'Tarballing SQL Package'
+    extension = 'tar.gz'
+    compression = Compression.GZIP
+    appendix = 'sql'
+  
+    basedir = baseName + '-' + appendix + '-' + version
+    packageDir = 'build/package/sql'
+    println 'Source is in ' + packageDir
+    into(basedir) { 
+        from packageDir
+    }
+} 
+
+/**
+ * Packages the Azkaban Executor Server
+ */
+task packageExec(type: Tar, dependsOn: [jar, 'createVersionFile']) {
+    appendix = 'exec-server'
+    String packageDir = 'build/package/' + baseName + '-' + appendix
+ 
+    println 'Creating Azkaban Executor Server Package into ' + packageDir
+    mkdir packageDir
+    mkdir packageDir + '/extlib'
+    mkdir packageDir + '/plugins'
+    
+    println 'Copying Exec server bin & conf'
+    copy {
+        from('src/package/execserver')
+        into(packageDir)
+    }
+    
+    println 'Copying Azkaban lib'
+    copy {
+        from('build/libs')
+        into(packageDir + '/lib')
+    }
+
+    println 'Copying dependency jars'
+    copy {
+        into packageDir + '/lib'
+        from configurations.compile
+    }
+    
+    copy {
+        into packageDir
+        from 'build/package/version.file'
+    }
+    
+    println 'Tarballing Web Package'
+    extension = 'tar.gz'
+    compression = Compression.GZIP
+  
+    basedir = baseName + '-' + appendix + '-' + version
+    packageDir = 'build/package/' + baseName + '-' + appendix
+    println 'Source is in ' + packageDir
+
+    into(basedir) { 
+        from packageDir
+        exclude 'bin'
+    }
+    
+    dst_bin = basedir + '/bin'
+    src_bin = packageDir + '/bin'
+    from(src_bin) { 
+        into dst_bin
+        fileMode = 0755
+    }
+}
+
+/**
+ * Packages the Azkaban Web Server
+ */
+task packageWeb(type: Tar, dependsOn: [jar, 'web', 'createVersionFile']) {
+    appendix = 'web-server'
+    String packageDir = 'build/package/' + baseName + '-' + appendix
+ 
+    println 'Creating Azkaban Web Server Package into ' + packageDir
+    mkdir packageDir
+    mkdir packageDir + '/extlib'
+    mkdir packageDir + '/plugins'
+    
+    println 'Copying Web server bin & conf'
+    copy {
+        from('src/package/webserver')
+        into(packageDir)
+    }
+    
+    println 'Copying Azkaban lib'
+    copy {
+        from('build/libs')
+        into(packageDir + '/lib')
+    }
+    
+    println 'Copying web'
+    copy {
+        from('build/web')
+        into(packageDir + '/web')
+    }
+
+    println 'Copying dependency jars'
+    copy {
+        into packageDir + '/lib'
+        from configurations.compile
+    }
+    
+    copy {
+        into packageDir
+        from 'build/package/version.file'
+    }
+    
+    println 'Tarballing Web Package'
+    extension = 'tar.gz'
+    compression = Compression.GZIP
+  
+    basedir = baseName + '-' + appendix + '-' + version
+    println 'Source is in ' + packageDir
+    into(basedir) { 
+        from packageDir
+        exclude 'bin'
+    }
+    
+    dst_bin = basedir + '/bin'
+    src_bin = packageDir + '/bin'
+    from(src_bin) { 
+        into dst_bin
+        fileMode = 0755
+    }
+}
+
+task packageAll(dependsOn : ['packageWeb', 'packageExec', 'packageSolo', 'packageSql']) {
+}
\ No newline at end of file

build.xml 6(+3 -3)

diff --git a/build.xml b/build.xml
index 1435ccf..bdbc243 100644
--- a/build.xml
+++ b/build.xml
@@ -21,9 +21,9 @@
   <property name="solo.package.dir" value="${basedir}/src/package/soloserver" />
 
   <property name="bin.dir" value="${basedir}/bin" />
-  <property name="java.src.dir" value="${basedir}/src/java" />
-  <property name="dust.src.dir" value="${basedir}/src/tl" />
-  <property name="less.src.dir" value="${basedir}/src/less" />
+  <property name="java.src.dir" value="${basedir}/src/main/java" />
+  <property name="dust.src.dir" value="${basedir}/src/main/tl" />
+  <property name="less.src.dir" value="${basedir}/src/main/less" />
   <property name="web.src.dir" value="${basedir}/src/web" />
   <property name="sql.src.dir" value="${basedir}/src/sql" />
 

ivy/.gitignore 2(+1 -1)

diff --git a/ivy/.gitignore b/ivy/.gitignore
index d392f0e..f23b948 100644
--- a/ivy/.gitignore
+++ b/ivy/.gitignore
@@ -1 +1 @@
-*.jar
+*.jar
\ No newline at end of file
diff --git a/ivy/ivysettings.xml b/ivy/ivysettings.xml
index 26bb444..ea6d163 100644
--- a/ivy/ivysettings.xml
+++ b/ivy/ivysettings.xml
@@ -24,4 +24,4 @@
       <resolver ref="oss-sonatype" />
     </chain>
   </resolvers>
-</ivysettings>
+</ivysettings>
\ No newline at end of file
diff --git a/ivy/libraries.properties b/ivy/libraries.properties
index ad9f00d..a8cd72f 100644
--- a/ivy/libraries.properties
+++ b/ivy/libraries.properties
@@ -29,4 +29,4 @@ servlet-api.version=2.5
 slf4j-api.version=1.6.1
 slf4j-log4j12.version=1.6.4
 velocity.version=1.7
-velocity-tools.version=2.0
+velocity-tools.version=2.0
\ No newline at end of file
diff --git a/src/main/java/azkaban/webapp/session/SessionCache.java b/src/main/java/azkaban/webapp/session/SessionCache.java
new file mode 100644
index 0000000..7591dbf
--- /dev/null
+++ b/src/main/java/azkaban/webapp/session/SessionCache.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2012 LinkedIn Corp.
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ * 
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package azkaban.webapp.session;
+
+import azkaban.utils.Props;
+import azkaban.utils.cache.Cache;
+import azkaban.utils.cache.CacheManager;
+import azkaban.utils.cache.Cache.EjectionPolicy;
+
+
+/**
+ * Cache for web session.
+ * 
+ * The following global azkaban properties can be used: max.num.sessions - used
+ * to determine the number of live sessions that azkaban will handle. Default is
+ * 10000 session.time.to.live -Number of seconds before session expires.
+ * Default set to 1 days.
+ */
+public class SessionCache {
+	private static final int MAX_NUM_SESSIONS = 10000;
+	private static final long SESSION_TIME_TO_LIVE = 24*60*60*1000L;
+
+//	private CacheManager manager = CacheManager.create();
+	private Cache cache;
+
+	/**
+	 * Constructor taking global props.
+	 * 
+	 * @param props
+	 */
+	public SessionCache(Props props) {
+		CacheManager manager = CacheManager.getInstance();
+		
+		cache = manager.createCache();
+		cache.setEjectionPolicy(EjectionPolicy.LRU);
+		cache.setMaxCacheSize(props.getInt("max.num.sessions", MAX_NUM_SESSIONS));
+		cache.setExpiryTimeToLiveMs(props.getLong("session.time.to.live", SESSION_TIME_TO_LIVE));
+	}
+
+	/**
+	 * Returns the cached session using the session id.
+	 * 
+	 * @param sessionId
+	 * @return
+	 */
+	public Session getSession(String sessionId) {
+		Session elem = cache.<Session>get(sessionId);
+
+		return elem;
+	}
+
+	/**
+	 * Adds a session to the cache. Accessible through the session ID.
+	 * 
+	 * @param id
+	 * @param session
+	 */
+	public void addSession(Session session) {
+		cache.put(session.getSessionId(), session);
+	}
+
+	/**
+	 * Removes the session from the cache.
+	 * 
+	 * @param id
+	 * @return
+	 */
+	public boolean removeSession(String id) {
+		return cache.remove(id);
+	}
+}
\ No newline at end of file
diff --git a/src/main/less/.gitignore b/src/main/less/.gitignore
new file mode 100644
index 0000000..2416a67
--- /dev/null
+++ b/src/main/less/.gitignore
@@ -0,0 +1 @@
+obj/
diff --git a/src/main/less/azkaban-graph.less b/src/main/less/azkaban-graph.less
new file mode 100644
index 0000000..50d818c
--- /dev/null
+++ b/src/main/less/azkaban-graph.less
@@ -0,0 +1,165 @@
+.nodebox {
+  text {
+    pointer-events: none;
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+
+  image {
+    pointer-events: none;
+  }
+
+  > .border:hover {
+    fill-opacity: 0.7;
+  }
+
+  > .flowborder:hover {
+    stroke-opacity: 0.7;
+  }
+}
+
+/* Nodes */
+.node {
+  &:hover {
+    cursor: pointer;
+  }
+
+  &.selected > .nodebox .border {
+    stroke-width: 3;
+    stroke: #39b3d7;
+  }
+
+  &.selected > .nodebox .flowborder {
+    stroke-width: 3;
+    fill: #D9EDFF;
+  }
+}
+
+.border {
+	stroke-width: 1;
+}
+
+.flownode .nodebox .flowborder {
+	stroke-width: 1.25;
+	fill: #FFF;
+	fill-opacity: 0.8;
+}
+
+.READY > g > rect {
+	fill: #DDD;
+	stroke: #CCC;
+}
+
+.READY > g > text {
+	fill: #000;
+}
+
+.RUNNING > g > rect {
+	fill: #39b3d7;
+	stroke: #39b3d7;
+}
+
+.RUNNING > g > text {
+	fill: #FFF;
+}
+
+.SUCCEEDED > g > rect {
+	fill: #5cb85c;
+	stroke: #4cae4c;
+}
+
+.SUCCEEDED > g > text {
+	fill: #FFF;
+}
+
+.FAILED > g > rect {
+	fill: #d2322d;
+	stroke: #d2322d;
+}
+
+.FAILED > g > text {
+	fill: #FFF;
+}
+
+.KILLED > g > rect {
+	fill: #d2322d;
+	stroke: #d2322d;
+}
+
+.KILLED > g > text {
+	fill: #FFF;
+}
+
+.CANCELLED > g > rect {
+	fill: #FF9999;
+	stroke: #FF9999;
+}
+
+.CANCELLED > g > text {
+	fill: #FFF;
+}
+
+.FAILED_FINISHING > g > rect {
+	fill: #ed9c28;
+	stroke: #ed9c28;
+}
+
+.FAILED_FINISHING > g > text {
+	fill: #FFF;
+}
+
+.DISABLED > g > rect {
+	fill: #DDD;
+	stroke: #CCC;
+}
+
+.DISABLED > g > rect {
+	fill: #DDD;
+	stroke: #CCC;
+}
+
+.nodeDisabled {
+	opacity: 0.25;
+}
+
+.SKIPPED > g > rect {
+	fill: #DDD;
+	stroke: #CCC;
+}
+
+.DISABLED {
+	opacity: 0.25;
+}
+
+.SKIPPED {
+	opacity: 0.25;
+}
+
+.QUEUED > g > rect {
+	fill: #39b3d7;
+	stroke: #39b3d7;
+}
+
+.QUEUED > g > text {
+	fill: #FFF;
+}
+
+.QUEUED {
+	opacity: 0.5;
+}
+
+/* Edges */
+.edge {
+	stroke: #CCC;
+	stroke-width: 1.5;
+
+  &:hover {
+    stroke: #009FC9;
+    stroke-width: 1.5;
+  }
+}
+
diff --git a/src/main/less/base.less b/src/main/less/base.less
new file mode 100644
index 0000000..62861ca
--- /dev/null
+++ b/src/main/less/base.less
@@ -0,0 +1,160 @@
+.container-full {
+  padding: 0 105px;
+  margin: 0 auto;
+  width: 100%;
+  max-width: none;
+  min-width: 1075px;
+}
+
+.container-fill {
+  position: absolute;
+  top: 230px;
+  bottom: 50px;
+
+  .row {
+    height: 100%;
+  }
+
+  .col-sidebar {
+    height: 100%;
+  }
+
+  .col-content {
+    height: 100%;
+  }
+}
+
+.alert-default {
+  color: #a0a0a0;
+  background-color: #f5f5f5;
+  border-color: #dddddd;
+
+  hr {
+    border-top-color: #cccccc;
+  }
+
+  .alert-link {
+    color: #a0a0a0;
+  }
+}
+
+// Wide modal used for certain panels such as executing flow panel.
+.modal-wide .modal-dialog {
+  width: 80%;
+}
+
+// Hide messaging alert by default.
+.alert-messaging {
+  display: none;
+}
+
+// Add additional space under navs.
+.nav-tabs, .nav-pills {
+  margin-bottom: 15px;
+}
+
+.panel-list {
+  border: 0;
+}
+
+.list-group-collapse {
+  margin: 0;
+  .list-group-item {
+    border-radius: 0;
+    border-left: 0;
+    border-right: 0;
+    
+    &:first-child {
+      border-top: 0;
+    }
+    
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    button {
+      margin-left: 3px;
+    }
+  }
+}
+
+@media (min-width: 768px) {
+  .form-horizontal .control-label-center {
+    text-align: center;
+  }
+}
+
+.well-clear {
+  background-color: transparent;
+}
+
+.nav {
+	.nav-button {
+		margin-left: 5px;
+	}
+}
+
+.state-icon {
+  background-image: url("../css/images/ui-icons_cccccc_256x240.png");
+  cursor: pointer;
+  display: block;
+  float: left;
+  height: 16px;
+  width: 16px;
+  margin-right: 5px;
+
+  &.state-icon-expand {
+    background-position: -32px -16px;
+  }
+
+  &.state-icon-collapse {
+    background-position: -64px -16px;
+  }
+
+  &.state-icon-wait {
+    background-position: -64px -80px;
+  }
+}
+
+.editable {
+  margin: 0px;
+  cursor: pointer;
+  &:hover {
+    background-color: #fcfcfc;
+  }
+  &.editable-placeholder {
+    color: #a0a0a0;
+  }
+}
+
+.editable-form {
+  display: none;
+}
+
+.nav.nav-sm > li > a {
+  padding: 8px 12px;
+  font-size: 13px;
+}
+
+.scrollable {
+  padding: 0;
+  overflow: auto;
+  margin-bottom: 20px;
+
+  table {
+    margin-bottom: 0;
+  }
+}
+
+.panel-scrollable {
+  padding: 0;
+  overflow: auto;
+
+  table {
+    margin-bottom: 0;
+  }
+}
+
+.form-control-auto {
+  width: auto;
+}
diff --git a/src/main/less/flow.less b/src/main/less/flow.less
new file mode 100644
index 0000000..8ee150a
--- /dev/null
+++ b/src/main/less/flow.less
@@ -0,0 +1,342 @@
+#svgDiv {
+    height: 100%;
+    padding: 0px;
+}
+
+#graphView {
+   -moz-user-select: none;
+   -khtml-user-select: none;
+   -webkit-user-select: none;
+   user-select: none;
+}
+
+#flow-graph {
+    width: 100%;
+    height: 100%;
+}
+
+#headertabs {
+   -moz-user-select: none;
+   -khtml-user-select: none;
+   -webkit-user-select: none;
+   user-select: none;
+}
+
+#flow-executing-graph {
+  width: 100%;
+  height: 500px;
+}
+
+.flow-progress {
+	width: 280px;
+	margin: 4px;
+  background-color: #f5f5f5;
+  height: 24px;
+  border-radius: 4px;
+  -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+          box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.flow-progress-bar {
+	height: 100%;
+  background-color: #ccc;
+  border-radius: 5px;
+  -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+          box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+  -webkit-transition: width 0.6s ease;
+          transition: width 0.6s ease;
+
+  &.attempt {
+    opacity: 0.70;
+    &:hover {
+      opacity: 1;
+    }
+  }
+
+  &.SUCCEEDED {
+    background-color: @flow-succeeded-color;
+  }
+
+  &.FAILED {
+    background-color: @flow-failed-color;
+  }
+  
+  &.KILLED {
+    background-color: @flow-killed-color;
+  }
+
+  &.RUNNING {
+    background-color: @flow-running-color;
+    background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));
+    background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+    background-image: -moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+    background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+    background-size: 40px 40px;
+    -webkit-animation: progress-bar-stripes 2s linear infinite;
+            animation: progress-bar-stripes 2s linear infinite;
+  }
+
+  &.QUEUED {
+    background-color: @flow-queued-color;
+  }
+  
+  &.CANCELLED {
+    background-color: @flow-cancelled-color;
+  }
+}
+
+td {
+	> .listExpand {
+		width: 16px;
+		height: 16px;
+		float:right;
+		margin-top: 5px;
+		font-size: 8pt;
+	}
+
+  .status {
+    -moz-border-radius: 2px;
+    border-radius: 2px;
+
+    padding: 2px 2px;
+    color: #FFF;
+    text-align: center;
+    margin-top: 2px;
+    
+    &.SUCCEEDED {
+      background-color: @flow-succeeded-color;
+    }
+
+    &.FAILED {
+      background-color: @flow-failed-color;
+    }
+    
+    &.KILLED {
+      background-color: @flow-killed-color;
+    }
+
+    &.PAUSED {
+      background-color: @flow-paused-color;
+    }
+
+    &.READY,
+    &.UNKNOWN,
+    &.PREPARING {
+      background-color: @flow-default-color;
+    }
+
+    &.RUNNING {
+      background-color: @flow-running-color;
+    }
+
+    &.FAILED_FINISHING {
+      background-color: @flow-failed-finishing-color;
+    }
+
+    &.DISABLED,
+    &.SKIPPED {
+      background-color: @flow-disabled-color;
+    }
+
+    &.CANCELLED {
+      background-color: @flow-cancelled-color;
+    }
+  }
+}
+
+#flowStatus {
+  &.SKIPPED {
+    color: @flow-disabled-color;
+  }
+
+  &.SUCCEEDED {
+    color: @flow-succeeded-color;
+  }
+
+  &.RUNNING {
+    color: @flow-running-color;
+  }
+
+  &.PAUSED {
+    color: @flow-paused-color;
+  }
+
+  &.FAILED {
+    color: @flow-failed-color;
+  }
+
+  &.KILLED {
+    color: @flow-killed-color;
+  }
+
+  &.CANCELLED {
+    color: @flow-cancelled-color;
+  }
+
+  &.FAILED_FINISHING {
+    color: @flow-failed-finishing-color;
+  }
+}
+
+.graph-sidebar {
+  height: 100%;
+  overflow-y: auto;
+
+  .graph-sidebar-list {
+    height: 100%;
+  }
+}
+
+.graph-sidebar-float {
+  position: absolute;
+  top: 0px;
+  bottom: 0px;
+
+  .graph-sidebar-list {
+    overflow-y: auto;
+    height: calc(~"100% - 102px");
+  }
+
+  .panel {
+    height: 100%;
+    
+    .panel-heading {
+      padding-right: 10px;
+    }
+  }
+}
+
+.graph-container {
+  height: 100%;
+
+  svg {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.graph-sidebar-search {
+	width: 206px;
+	margin: 0px;
+}
+
+.graph-sidebar-close {
+	float: right;
+	color: #CCC;
+	padding: 5px 0px;
+	cursor: pointer;
+
+	&:hover {
+	  color: #666;
+	}
+}
+
+.graph-sidebar-open {
+	position: absolute;
+	margin: 10px;
+	color: #CCC;
+	cursor: pointer;
+	
+	&:hover {
+		color: #666;
+	}
+}
+
+ul.tree-list {
+  list-style-type: none;
+  padding-left: 0px;
+  margin: 0;
+}
+  
+li.tree-list-item {
+  &.active > a {
+    background-color: #D9EDFF;
+  }
+
+  ul.tree-list {
+    padding-left: 20px;
+  }
+
+  &.subFilter > a > .expandarrow {
+    color : #f19153;
+  }
+
+  > a {
+    clear: both;
+    position: relative;
+    display: block;
+    border-bottom-width: 0;
+    padding: 5px 15px;
+    font-size: 10pt;
+
+    &:hover,
+    &:focus {
+      text-decoration: none;
+      background-color: #f5f5f5;
+      cursor: pointer;
+    }
+  
+    &.nodedisabled,
+    &.DISABLED {
+      opacity: 0.3;
+    }
+    
+    &.DISABLED .icon {
+      background-position: 16px 0px;
+    }
+    
+    &.READY .icon {
+      background-position: 16px 0px;
+    }
+    
+    &.QUEUED .icon {
+      opacity: 0.5;
+      background-position: 32px 0px;
+    }
+    
+    &.RUNNING .icon {
+      background-position: 32px 0px;
+    }
+    
+    &.SUCCEEDED .icon {
+      background-position: 48px 0px;
+    }
+    
+    &.FAILED .icon {
+      background-position: 0px 0px;
+    }
+    
+    &.KILLED .icon {
+      background-position: 0px 0px;
+    }
+    
+    &.CANCELLED .icon {
+      background-position: 0px 0px;
+      opacity: 0.5;
+    }
+    
+    &.FAILED_FINISHING .icon {
+      background-position: 0px 0px;
+    }
+    
+    .icon {
+      float: left;
+      width: 16px;
+      height: 16px;
+      margin: 2px 4px 0px -5px;
+      background-image: url("./images/dot-icon.png");
+      background-position: 16px 0px;
+    }
+    
+    .expandarrow {
+      float: right;
+      width: 16px;
+      height: 16px;
+      font-size: 8pt;
+    }
+    
+    .filterHighlight {
+      background-color: #FFFF00;
+    }
+  }
+}
diff --git a/src/main/less/navbar.less b/src/main/less/navbar.less
new file mode 100644
index 0000000..3826082
--- /dev/null
+++ b/src/main/less/navbar.less
@@ -0,0 +1,121 @@
+.navbar-logo {
+  float: left;
+  padding: 15px 15px;
+  font-size: 18px;
+  line-height: 20px;
+
+  a {
+    &:hover,
+    &:focus {
+      text-decoration: none;
+    }
+  }
+}
+
+@media (min-width: 768px) {
+  .navbar > .container .navbar-logo {
+    margin-left: -15px;
+  }
+}
+
+@media (min-width: 768px) {
+  .navbar-enviro {
+    width: auto;
+  }
+}
+
+.navbar-enviro {
+  margin: 30px 20px 0px 12px;
+     
+  .navbar-enviro-name {
+    color: #ff3601;
+    font-family: Helvetica, Arial, Sans-Serif;
+    font-size: 118.75%;
+    font-weight: bold;
+		line-height: 100%;
+  }
+       
+  .navbar-enviro-server {
+    color: #999;
+    font-family: Helvetica, Arial, Sans-Serif;
+    font-size: 75%;
+  }
+}
+
+// Restyle navbar-inverse for Azkaban navbar.
+.navbar-inverse {
+  background-color: #383838;
+  border-top: 5px solid #ff3601;
+  margin-bottom: 0;
+
+  .navbar-logo {
+    background: url('../../images/logo.png') top left no-repeat;
+    color: #ffffff;
+    font-size: 156.25%;
+    font-weight: bold;
+    margin: 15px 0.6% 15px 4.75%;
+    padding: 12px 0 11px 42px;
+
+    a {
+      color: #ffffff;
+      &:hover,
+      &:focus {
+        color: #ffffff;
+        background-color: transparent;
+      }
+    }
+  }
+
+  .navbar-nav {
+    font-family: Arial;
+    font-size: 81.25%;
+
+    > li {
+      padding: 25px 12px 25px 12px;
+      cursor: pointer;
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.1);
+      }
+    }
+
+    > li > a {
+      padding: 0px;
+      color: #ccc;
+      &:focus,
+      &:hover {
+        color: #ccc;
+        background-color: transparent;
+      }
+    }
+
+    > .active {
+      background-color: rgba(0, 0, 0, 0.1);
+    }
+
+    > .active > a {
+      color: #fff;
+      font-weight: bold;
+      background-color: transparent;
+      border-bottom: 1px solid #ff3601;
+      &:hover {
+        color: #fff;
+        background-color: transparent;
+      }
+    }
+
+    > li.dropdown {
+      padding: 0;
+    }
+
+    > li.dropdown > a {
+      padding: 25px 12px 25px 12px;
+    }
+
+    > .open > a,
+    > .open > a:hover,
+    > .open > a:focus {
+      color: #ccc;
+      background-color: transparent;
+    }
+  }
+}
diff --git a/src/main/less/non-responsive.less b/src/main/less/non-responsive.less
new file mode 100644
index 0000000..7fa282c
--- /dev/null
+++ b/src/main/less/non-responsive.less
@@ -0,0 +1,88 @@
+/* Non-responsive overrides
+ *
+ * Utilitze the following CSS to disable the responsive-ness of the container,
+ * grid system, and navbar.
+ */
+
+/* Reset the container */
+.container {
+  max-width: none !important;
+  width: 970px;
+}
+
+.container .navbar-header,
+.container .navbar-collapse {
+  margin-right: 0;
+  margin-left: 0;
+}
+
+/* Always float the navbar header */
+.navbar-header {
+  float: left;
+}
+
+/* Undo the collapsing navbar */
+.navbar-collapse {
+  display: block !important;
+  height: auto !important;
+  padding-bottom: 0;
+  overflow: visible !important;
+}
+
+.navbar-toggle {
+  display: none;
+}
+.navbar-collapse {
+  border-top: 0;
+}
+
+.navbar-brand {
+  margin-left: -15px;
+}
+
+/* Always apply the floated nav */
+.navbar-nav {
+  float: left;
+  margin: 0;
+}
+.navbar-nav > li {
+  float: left;
+}
+.navbar-nav > li > a {
+  padding: 15px;
+}
+
+/* Redeclare since we override the float above */
+.navbar-nav.navbar-right {
+  float: right;
+}
+
+/* Undo custom dropdowns */
+.navbar .navbar-nav .open .dropdown-menu {
+  position: absolute;
+  float: left;
+  background-color: #fff;
+  border: 1px solid #cccccc;
+  border: 1px solid rgba(0, 0, 0, 0.15);
+  border-width: 0 1px 1px;
+  border-radius: 0 0 4px 4px;
+  -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
+          box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
+}
+.navbar-default .navbar-nav .open .dropdown-menu > li > a {
+  color: #333;
+}
+.navbar .navbar-nav .open .dropdown-menu > li > a:hover,
+.navbar .navbar-nav .open .dropdown-menu > li > a:focus,
+.navbar .navbar-nav .open .dropdown-menu > .active > a,
+.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
+.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
+  color: #fff !important;
+  background-color: #428bca !important;
+}
+.navbar .navbar-nav .open .dropdown-menu > .disabled > a,
+.navbar .navbar-nav .open .dropdown-menu > .disabled > a:hover,
+.navbar .navbar-nav .open .dropdown-menu > .disabled > a:focus {
+  color: #999 !important;
+  background-color: transparent !important;
+}
diff --git a/src/main/less/project.less b/src/main/less/project.less
new file mode 100644
index 0000000..0fb63af
--- /dev/null
+++ b/src/main/less/project.less
@@ -0,0 +1,142 @@
+#project-list {
+  padding: 0;
+  margin: 0px 0px 40px 0px;
+
+  li {
+    list-style: none;
+    border-bottom: 1px solid #cccccc;
+    padding-top: 14px;
+    padding-bottom: 0px;
+    &:first-child {
+      border-top: 1px solid #cccccc;
+    }
+  }
+
+  .project-expander {
+    float: right;
+    cursor: pointer;
+    &:hover {
+      color: #2a6496;
+    }
+  }
+
+  .project-info {
+    float: left;
+    h4 {
+      margin-top: 0;
+      margin-bottom: 4px;
+    }
+    
+    .project-description {
+      margin-bottom: 4px;
+    }
+
+    .project-last-modified {
+      color: #a0a0a0;
+      margin-bottom: 16px;
+      strong {
+        font-weight: normal;
+        color: #000000;
+      }
+    }
+  }
+}
+
+#project-sidebar {
+  h3 {
+    margin-bottom: 5px;
+  }
+}
+
+.project-flows {
+	display: none;
+	background-color: #f9f9f9;
+	padding: 10px 15px 10px 15px;
+
+	h5 {
+		margin-top: 5px;
+	}
+
+	.list-group {
+		margin-bottom: 10px;
+	}
+
+	.list-group-item {
+		background: transparent;
+		padding: 7px 12px 7px 12px;
+	}
+}
+
+// Flow panel heading.
+.flow-expander {
+  cursor: pointer;
+
+  .flow-expander-icon {
+    color: #9a9a9a;
+    margin-right: 5px;
+  }
+}
+
+.expanded-flow-job-list {
+  .list-group-item {
+    .job-buttons {
+      visibility: hidden;
+    }
+
+    &:hover {
+      background-color: #f5f5f5;
+
+      .job-buttons {
+        visibility: visible;
+      }
+    }
+  }
+
+  .dependency {
+    background-color: #f0f0f0;
+  }
+
+  .dependent {
+    background-color: #fafafa;
+  }
+}
+
+// Permissions page table.
+.permission-table {
+  .tb-perm {
+    width: 41px;
+    margin: 0px;
+  }
+
+  .tb-admin {
+    width: 41px;
+    margin: 0px;
+  }
+
+  .tb-read {
+    width: 33px;
+    margin: 0px;
+  }
+
+  .tb-write {
+    width: 34px;
+    margin: 0px;
+  }
+
+  .tb-execute {
+    width: 51px;
+    margin: 0px;
+  }
+
+  .tb-schedule {
+    margin: 0px;
+    width: 60px;
+  }
+
+  .tb-action {
+    margin: 0px;
+    width: 70px;
+    min-width: 70px;
+    max-width: 70px;
+  }
+}
diff --git a/src/main/less/tables.less b/src/main/less/tables.less
new file mode 100644
index 0000000..5d5aeea
--- /dev/null
+++ b/src/main/less/tables.less
@@ -0,0 +1,149 @@
+table.table-properties {
+  table-layout: fixed;
+  word-wrap: break-word;
+}
+
+// Flow summary.
+.property-key {
+  width: 25%;
+  font-weight: bold;
+}
+
+.property-value {
+
+}
+
+.property-value-half {
+  width: 25%;
+}
+
+.property-key,
+.property-value,
+.property-value-half {
+	pre {
+		background: transparent;
+		padding: 0;
+		border: 0;
+	}
+}
+
+.editable {
+  .remove-btn {
+    visibility: hidden;
+  }
+
+  &:hover .remove-btn {
+    visibility: visible;
+  }
+}
+
+// Job table.
+#all-jobs {
+  .tb-name {
+    width: 70%;
+    border-bottom-width: 0;
+    border-bottom-style: none;
+  }
+
+  .tb-up-date {
+    width: 140px;
+    min-width: 130px;
+  }
+
+  .tb-owner {
+    width: 10%;
+    min-width: 95px;
+  }
+}
+
+// Properties table.
+.properties-table {
+  .all-jobs .tb-pname {
+  }
+
+  .all-jobs .tb-pvalue {
+  }
+}
+
+// Table of executions.
+.executions-table {
+  tr {
+    &.expanded {
+      opacity: 0.6;
+    }
+  }
+
+  td {
+  	&.subflowrow {
+  		padding: 0px 0px;
+  	
+  		table {
+  			margin: 0px;
+  			background-color: rgba(230, 230, 230, 0.75);
+  			
+  			td {
+  				background-color: none;
+  			}
+  		}
+  	}
+
+    &.date {
+      width: 160px;
+    }
+
+    &.jobtype {
+      width: 90px;
+    }
+
+    &.execid {
+      width: 100px;
+    }
+
+    &.project {
+      width: 200px;
+    }
+
+    &.user {
+      width: 60px;
+    }
+
+    &.elapse {
+      width: 90px;
+    }
+
+    &.statustd {
+      width: 100px;
+    }
+
+    &.details {
+      width: 10px;
+    }
+
+    &.action {
+      width: 20px;
+    }
+
+    &.logs {
+      width: 30px;
+    }
+    
+     &.timeline {
+      width: 280px;
+      padding: 0px 0px 0px 4px;
+      height: 100%;
+      vertical-align: bottom;
+      margin: 0px;
+    }
+    
+    &.startTime {
+  		width: 160px;
+  	}
+  	
+  	&.endTime {
+  		width: 160px;
+  	}
+    &.elapsedTime {
+  		width: 90px;
+  	}
+  }
+}
diff --git a/src/main/resources/azkaban/webapp/servlet/velocity/triggerspage.vm b/src/main/resources/azkaban/webapp/servlet/velocity/triggerspage.vm
new file mode 100644
index 0000000..c48ec9e
--- /dev/null
+++ b/src/main/resources/azkaban/webapp/servlet/velocity/triggerspage.vm
@@ -0,0 +1,95 @@
+#*
+ * Copyright 2012 LinkedIn, Inc
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ * 
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+*#
+
+<!DOCTYPE html> 
+<html lang="en">
+	<head>
+
+#parse("azkaban/webapp/servlet/velocity/style.vm")
+#parse("azkaban/webapp/servlet/velocity/javascript.vm")
+
+		<link rel="stylesheet" type="text/css" href="${context}/css/jquery-ui-1.10.1.custom.css" />
+		<link rel="stylesheet" type="text/css" href="${context}/css/jquery-ui.css" />
+		
+		<script type="text/javascript" src="${context}/js/jqueryui/jquery-ui-1.10.1.custom.js"></script>
+		<script type="text/javascript" src="${context}/js/jqueryui/jquery-ui-timepicker-addon.js"></script> 
+		<script type="text/javascript" src="${context}/js/jqueryui/jquery-ui-sliderAccess.js"></script>
+
+		<script type="text/javascript" src="${context}/js/azkaban/view/table-sort.js"></script>
+		<script type="text/javascript" src="${context}/js/azkaban/view/triggers.js"></script>
+		<script type="text/javascript">
+			var contextURL = "${context}";
+			var currentTime = ${currentTime};
+			var timezone = "${timezone}";
+			var errorMessage = null;
+			var successMessage = null;
+		</script>
+	</head>
+	<body>
+
+#set ($current_page="triggers")
+#parse ("azkaban/webapp/servlet/velocity/nav.vm")
+
+#if ($errorMsg)
+  #parse ("azkaban/webapp/servlet/velocity/errormsg.vm")
+#else
+
+    <div class="az-page-header">
+      <div class="container-full">
+        <h1>All Triggers</h1>
+      </div>
+    </div>
+			
+    <div class="container-full">
+
+  #parse ("azkaban/webapp/servlet/velocity/alerts.vm")
+
+			<div class="row">
+        <div class="col-xs-12">
+          <table id="triggersTbl" class="table table-striped table-bordered table-condensed table-hover">
+            <thead>
+              <tr>
+                <th>ID</th>
+                <th>Source</th>
+                <th>Submitted By</th>
+                <th>Description</th>
+                <th>Status</th>
+                <!--th colspan="2" class="action ignoresort">Action</th-->
+              </tr>
+            </thead>
+            <tbody>
+  #if ($triggers)
+    #foreach ($trigger in $triggers)
+              <tr>
+                <td>${trigger.triggerId}</td>
+                <td>${trigger.source}</td>
+                <td>${trigger.submitUser}</td>
+                <td>${trigger.getDescription()}</td>
+                <td>${trigger.getStatus()}</td>
+                <!--td><button id="expireTriggerBtn" onclick="expireTrigger(${trigger.triggerId})" >Expire Trigger</button></td-->
+              </tr>
+    #end
+  #else
+              <tr><td class="last">No Trigger Found</td></tr>
+  #end
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+#end
+	</body>
+</html>
diff --git a/src/main/tl/.gitignore b/src/main/tl/.gitignore
new file mode 100644
index 0000000..2416a67
--- /dev/null
+++ b/src/main/tl/.gitignore
@@ -0,0 +1 @@
+obj/
diff --git a/src/main/tl/flowstats.tl b/src/main/tl/flowstats.tl
new file mode 100644
index 0000000..5276ce8
--- /dev/null
+++ b/src/main/tl/flowstats.tl
@@ -0,0 +1,155 @@
+      {?histogram}
+      <div class="row">
+        <div class="col-xs-12">
+          <div class="well well-clear well-sm">
+            <div id="job-histogram"></div>
+          </div>
+        </div>
+      </div>
+      {/histogram}
+
+      {?warnings}
+      <div class="alert alert-warning">
+        <h4>Warnings</h4>
+        <p>These stats may have reduced accuracy due to the following missing information:</p>
+        <ul>
+        {#warnings}
+          <li>{.}</li>
+        {/warnings}
+        </ul>
+      </div>
+      {/warnings}
+
+      <div class="row">
+        <div class="col-xs-12">
+          <h4>Resources</h4>
+          <table class="table table-bordered table-condensed table-striped">
+            <thead>
+              <tr>
+                <th class="property-key">Resource</th>
+                <th class="property-key">Value</th>
+                <th>Job Name</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="property-key">Max Map Slots</td>
+                <td>{stats.mapSlots.max}</td>
+                <td>{stats.mapSlots.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max Reduce Slots</td>
+                <td>{stats.reduceSlots.max}</td>
+                <td>{stats.reduceSlots.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Total Map Slots</td>
+                <td colspan="2">{stats.totalMapSlots}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Total Reduce Slots</td>
+                <td colspan="2">{stats.totalReduceSlots}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div class="row">
+        <div class="col-xs-12">
+          <h4>Parameters</h4>
+          <table class="table table-bordered table-condensed table-striped">
+            <thead>
+              <tr>
+                <th class="property-key">Parameter</th>
+                <th class="property-key">Value</th>
+                <th>Job Name</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="property-key">Max <code>-Xmx</code></td>
+                <td>{stats.xmx.str}</td>
+                <td>{stats.xmx.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>-Xms</code></td>
+                {?stats.xms.set}
+                <td>
+                  {stats.xms.str}
+                </td>
+                <td>
+                  {stats.xms.job}
+                </td>
+                {:else}
+                <td colspan="2">
+                  Not set.
+                </td>
+                {/stats.xms.set}
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>mapred.job.map.memory.mb</code></td>
+                <td>{stats.jobMapMemoryMb.max}</td>
+                <td>{stats.jobMapMemoryMb.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>mapred.job.reduce.memory.mb</code></td>
+                <td>{stats.jobReduceMemoryMb.max}</td>
+                <td>{stats.jobReduceMemoryMb.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max Distributed Cache</td>
+                {?stats.distributedCache.using}
+                <td>
+                  {stats.distributedCache.max}
+                </td>
+                <td>
+                  {stats.distributedCache.job}
+                </td>
+                {:else}
+                <td colspan="2">
+                  Not used.
+                </td>
+                {/stats.distributedCache.using}
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div class="row">
+        <div class="col-xs-12">
+          <h4>Counters</h4>
+          <table class="table table-bordered table-condensed">
+            <thead>
+              <tr>
+                <th class="property-key">Parameter</th>
+                <th class="property-key">Value</th>
+                <th>Job Name</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="property-key">Max <code>FILE_BYTES_READ</code></td>
+                <td>{stats.fileBytesRead.max}</td>
+                <td>{stats.fileBytesRead.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>HDFS_BYTES_READ</code></td>
+                <td>{stats.hdfsBytesRead.max}</td>
+                <td>{stats.hdfsBytesRead.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>FILE_BYTES_WRITTEN</code></td>
+                <td>{stats.fileBytesWritten.max}</td>
+                <td>{stats.fileBytesWritten.job}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Max <code>HDFS_BYTES_WRITTEN</code></td>
+                <td>{stats.hdfsBytesWritten.max}</td>
+                <td>{stats.hdfsBytesWritten.job}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
diff --git a/src/main/tl/flowsummary.tl b/src/main/tl/flowsummary.tl
new file mode 100644
index 0000000..82627d6
--- /dev/null
+++ b/src/main/tl/flowsummary.tl
@@ -0,0 +1,69 @@
+      <div class="row">
+        <div class="col-xs-12">
+          <table class="table table-bordered table-condensed">
+            <tbody>
+              <tr>
+                <td class="property-key">Project name</td>
+                <td>{projectName}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Job Types Used</td>
+                <td>{#jobTypes}{.} {/jobTypes}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div class="row">
+        <div class="col-xs-12">
+          <h3>
+            Scheduling
+            {?schedule}
+            <div class="pull-right">
+              <button type="button" id="removeSchedBtn" class="btn btn-sm btn-danger" onclick="removeSched({schedule.scheduleId})" >Remove Schedule</button>
+            </div>
+            {/schedule}
+          </h3>
+          {?schedule}
+          <table class="table table-condensed table-bordered">
+            <tbody>
+              <tr>
+                <td class="property-key">Schedule ID</td>
+                <td class="property-value-half">{schedule.scheduleId}</td>
+                <td class="property-key">Submitted By</td>
+                <td class="property-value-half">{schedule.submitUser}</td>
+              </tr>
+              <tr>
+                <td class="property-key">First Scheduled to Run</td>
+                <td class="property-value-half">{schedule.firstSchedTime}</td>
+                <td class="property-key">Repeats Every</td>
+                <td class="property-value-half">{schedule.period}</td>
+              </tr>
+              <tr>
+                <td class="property-key">Next Execution Time</td>
+                <td class="property-value-half">{schedule.nextExecTime}</td>
+                <td class="property-key">SLA</td>
+                <td class="property-value-half">
+                {?schedule.slaOptions}
+                  true
+                {:else}
+                  false
+                {/schedule.slaOptions}
+                  <div class="pull-right">
+                    <button type="button" id="addSlaBtn" class="btn btn-xs btn-primary" onclick="slaView.initFromSched({schedule.scheduleId}, '{flowName}')" >View/Set SLA</button>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+          {:else}
+            <div class="callout callout-default">
+              <h4>None</h4>
+              <p>This flow has not been scheduled.</p>
+            </div>
+          {/schedule}
+
+          <h3>Last Run Stats</h3>
+        </div>
+      </div>
diff --git a/unit/java/azkaban/test/execapp/FlowRunnerPipelineTest.java b/unit/java/azkaban/test/execapp/FlowRunnerPipelineTest.java
index 9df97ac..aa1eee5 100644
--- a/unit/java/azkaban/test/execapp/FlowRunnerPipelineTest.java
+++ b/unit/java/azkaban/test/execapp/FlowRunnerPipelineTest.java
@@ -5,10 +5,9 @@ import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
-import junit.framework.Assert;
-
 import org.apache.commons.io.FileUtils;
 import org.apache.log4j.Logger;
+import org.junit.Assert;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/unit/java/azkaban/test/execapp/FlowRunnerTest2.java b/unit/java/azkaban/test/execapp/FlowRunnerTest2.java
index 9605c12..7a67da6 100644
--- a/unit/java/azkaban/test/execapp/FlowRunnerTest2.java
+++ b/unit/java/azkaban/test/execapp/FlowRunnerTest2.java
@@ -5,10 +5,9 @@ import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
-import junit.framework.Assert;
-
 import org.apache.commons.io.FileUtils;
 import org.apache.log4j.Logger;
+import org.junit.Assert;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/unit/java/azkaban/test/executor/ExecutableFlowTest.java b/unit/java/azkaban/test/executor/ExecutableFlowTest.java
index 9f2a3c2..6e4b0b1 100644
--- a/unit/java/azkaban/test/executor/ExecutableFlowTest.java
+++ b/unit/java/azkaban/test/executor/ExecutableFlowTest.java
@@ -8,9 +8,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
-import junit.framework.Assert;
-
 import org.apache.log4j.Logger;
+import org.junit.Assert;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/unit/java/azkaban/test/utils/DirectoryFlowLoaderTest.java b/unit/java/azkaban/test/utils/DirectoryFlowLoaderTest.java
index d671e6f..800df48 100644
--- a/unit/java/azkaban/test/utils/DirectoryFlowLoaderTest.java
+++ b/unit/java/azkaban/test/utils/DirectoryFlowLoaderTest.java
@@ -2,9 +2,8 @@ package azkaban.test.utils;
 
 import java.io.File;
 
-import junit.framework.Assert;
-
 import org.apache.log4j.Logger;
+import org.junit.Assert;
 import org.junit.Test;
 
 import azkaban.utils.DirectoryFlowLoader;