azkaban-aplcache

move jobsummary web plugin to main AZ repo (#1775) This PR

6/4/2018 8:24:53 PM

Details

diff --git a/az-jobsummary/build.gradle b/az-jobsummary/build.gradle
new file mode 100644
index 0000000..f87fe52
--- /dev/null
+++ b/az-jobsummary/build.gradle
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2018 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.
+ */
+
+apply plugin: 'distribution'
+
+dependencies {
+    compile project(':az-core')
+    compile project(":azkaban-common")
+    compile project(":azkaban-web-server")
+}
+
+distributions {
+    main {
+        contents {
+            from(jar) {
+                into 'lib'
+            }
+        }
+    }
+}
diff --git a/az-jobsummary/conf/plugin.properties b/az-jobsummary/conf/plugin.properties
new file mode 100644
index 0000000..f592c6a
--- /dev/null
+++ b/az-jobsummary/conf/plugin.properties
@@ -0,0 +1,7 @@
+viewer.name=Summary
+viewer.path=jobsummary
+viewer.order=1
+viewer.hidden=true
+viewer.external.classpaths=extlib/*
+viewer.servlet.class=azkaban.viewer.jobsummary.JobSummaryServlet
+viewer.jobtypes=java,hadoopJava,pig,hive
diff --git a/az-jobsummary/src/azkaban/viewer/jobsummary/JobSummaryServlet.java b/az-jobsummary/src/azkaban/viewer/jobsummary/JobSummaryServlet.java
new file mode 100644
index 0000000..94e19d8
--- /dev/null
+++ b/az-jobsummary/src/azkaban/viewer/jobsummary/JobSummaryServlet.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2018 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.viewer.jobsummary;
+
+import azkaban.executor.ExecutableFlow;
+import azkaban.executor.ExecutableNode;
+import azkaban.executor.ExecutorManagerAdapter;
+import azkaban.executor.ExecutorManagerException;
+import azkaban.project.Project;
+import azkaban.project.ProjectManager;
+import azkaban.server.session.Session;
+import azkaban.user.Permission;
+import azkaban.user.Permission.Type;
+import azkaban.user.User;
+import azkaban.utils.Props;
+import azkaban.webapp.AzkabanWebServer;
+import azkaban.webapp.plugin.PluginRegistry;
+import azkaban.webapp.plugin.ViewerPlugin;
+import azkaban.webapp.servlet.LoginAbstractAzkabanServlet;
+import azkaban.webapp.servlet.Page;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.log4j.Logger;
+
+public class JobSummaryServlet extends LoginAbstractAzkabanServlet {
+  private static final String PROXY_USER_SESSION_KEY =
+      "hdfs.browser.proxy.user";
+  private static final String HADOOP_SECURITY_MANAGER_CLASS_PARAM =
+      "hadoop.security.manager.class";
+  private static final Logger logger = Logger.getLogger(JobSummaryServlet.class);
+
+  private final Props props;
+  private final File webResourcesPath;
+
+  private final String viewerName;
+  private final String viewerPath;
+
+  private ExecutorManagerAdapter executorManager;
+  private ProjectManager projectManager;
+
+  private String outputDir;
+
+  public JobSummaryServlet(final Props props) {
+    this.props = props;
+    this.viewerName = props.getString("viewer.name");
+    this.viewerPath = props.getString("viewer.path");
+
+    this.webResourcesPath =
+        new File(new File(props.getSource()).getParentFile().getParentFile(),
+            "web");
+    this.webResourcesPath.mkdirs();
+    setResourceDirectory(this.webResourcesPath);
+  }
+
+  private Project getProjectByPermission(final int projectId, final User user,
+      final Permission.Type type) {
+    final Project project = this.projectManager.getProject(projectId);
+    if (project == null) {
+      return null;
+    }
+    if (!hasPermission(project, user, type)) {
+      return null;
+    }
+    return project;
+  }
+
+  @Override
+  public void init(final ServletConfig config) throws ServletException {
+    super.init(config);
+    final AzkabanWebServer server = (AzkabanWebServer) getApplication();
+    this.executorManager = server.getExecutorManager();
+    this.projectManager = server.getProjectManager();
+  }
+
+  private void handleViewer(final HttpServletRequest req, final HttpServletResponse resp,
+      final Session session) throws ServletException, IOException {
+
+    final Page page =
+        newPage(req, resp, session,
+            "azkaban/viewer/jobsummary/velocity/jobsummary.vm");
+    page.add("viewerPath", this.viewerPath);
+    page.add("viewerName", this.viewerName);
+
+    final User user = session.getUser();
+    final int execId = getIntParam(req, "execid");
+    final String jobId = getParam(req, "jobid");
+    final int attempt = getIntParam(req, "attempt", 0);
+
+    page.add("execid", execId);
+    page.add("jobid", jobId);
+    page.add("attempt", attempt);
+
+    ExecutableFlow flow = null;
+    ExecutableNode node = null;
+    try {
+      flow = this.executorManager.getExecutableFlow(execId);
+      if (flow == null) {
+        page.add("errorMsg", "Error loading executing flow " + execId
+            + ": not found.");
+        page.render();
+        return;
+      }
+
+      node = flow.getExecutableNodePath(jobId);
+      if (node == null) {
+        page.add("errorMsg",
+            "Job " + jobId + " doesn't exist in " + flow.getExecutionId());
+        return;
+      }
+
+      final List<ViewerPlugin> jobViewerPlugins =
+          PluginRegistry.getRegistry().getViewerPluginsForJobType(
+              node.getType());
+      page.add("jobViewerPlugins", jobViewerPlugins);
+    } catch (final ExecutorManagerException e) {
+      page.add("errorMsg", "Error loading executing flow: " + e.getMessage());
+      page.render();
+      return;
+    }
+
+    final int projectId = flow.getProjectId();
+    final Project project = getProjectByPermission(projectId, user, Type.READ);
+    if (project == null) {
+      page.render();
+      return;
+    }
+
+    page.add("projectName", project.getName());
+    page.add("flowid", flow.getId());
+    page.add("parentflowid", node.getParentFlow().getFlowId());
+    page.add("jobname", node.getId());
+
+    page.render();
+  }
+
+  private void handleDefault(final HttpServletRequest request,
+      final HttpServletResponse response, final Session session) throws ServletException,
+      IOException {
+    final Page page =
+        newPage(request, response, session,
+            "azkaban/viewer/jobsummary/velocity/jobsummary.vm");
+    page.add("viewerPath", this.viewerPath);
+    page.add("viewerName", this.viewerName);
+    page.add("errorMsg", "No job execution specified.");
+    page.render();
+  }
+
+  @Override
+  protected void handleGet(final HttpServletRequest request,
+      final HttpServletResponse response, final Session session) throws ServletException,
+      IOException {
+    if (hasParam(request, "execid") && hasParam(request, "jobid")) {
+      handleViewer(request, response, session);
+    } else {
+      handleDefault(request, response, session);
+    }
+  }
+
+  @Override
+  protected void handlePost(final HttpServletRequest request,
+      final HttpServletResponse response, final Session session) throws ServletException,
+      IOException {
+  }
+}
diff --git a/az-jobsummary/src/azkaban/viewer/jobsummary/velocity/jobsummary.vm b/az-jobsummary/src/azkaban/viewer/jobsummary/velocity/jobsummary.vm
new file mode 100644
index 0000000..a25baff
--- /dev/null
+++ b/az-jobsummary/src/azkaban/viewer/jobsummary/velocity/jobsummary.vm
@@ -0,0 +1,123 @@
+#*
+ * 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.
+*#
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+
+#parse ("azkaban/webapp/servlet/velocity/style.vm")
+#parse ("azkaban/webapp/servlet/velocity/javascript.vm")
+
+    <script type="text/javascript" src="${context}/js/azkaban/util/ajax.js"></script>
+    <script type="text/javascript" src="${context}/jobsummary/js/azkaban/view/job-summary.js"></script>
+    <script type="text/javascript" src="${context}/jobsummary/js/azkaban/model/log-data.js"></script>
+    <script type="text/javascript">
+      var contextURL = "${context}";
+      var currentTime = ${currentTime};
+      var timezone = "${timezone}";
+      var errorMessage = null;
+      var successMessage = null;
+
+      var projectName = "${projectName}";
+      var flowName = "${flowid}";
+      var execId = "${execid}";
+      var jobId = "${jobid}";
+      var attempt = ${attempt};
+    </script>
+  </head>
+  <body>
+
+#set ($current_page = "$viewerName")
+#parse ("azkaban/webapp/servlet/velocity/nav.vm")
+
+#if ($errorMsg)
+  #parse ("azkaban/webapp/servlet/velocity/errormsg.vm")
+#else
+
+  #parse ("azkaban/webapp/servlet/velocity/jobdetailsheader.vm")
+
+    <div class="container-full" id="jobSummaryView">
+      <div class="row">
+        <div class="col-lg-12">
+          <h3>
+            Job Summary
+            <div class="pull-right">
+              <button type="button" id="update-summary-btn" class="btn btn-xs btn-default">Refresh</button>
+            </div>
+          </h3>
+
+          <div class="callout callout-default" id="placeholder">
+            <h4>Job Summary not available</h4>
+            <p>There is no summary information available for this job yet. Try waiting and clicking Refresh.</p>
+          </div>
+
+          <div id="job-type">
+            <table id="job-type-table" class="table table-striped table-bordered table-hover">
+            </table>
+          </div>
+
+          <div id="command-summary">
+            <h4>Command Summary</h4>
+            <table id="command-table" class="table table-striped table-bordered table-hover">
+            </table>
+          </div>
+
+          <div id="pig-job-summary">
+            <h4>Pig Job Summary</h4>
+            <table class="table table-striped table-bordered table-hover">
+              <thead id="summary-header">
+              </thead>
+              <tbody id="summary-body">
+              </tbody>
+            </table>
+          </div>
+
+          <div id="pig-job-stats">
+            <h4>Pig Job Stats</h4>
+            <div class="scrollable">
+              <table class="table table-striped table-bordered table-hover table-condensed">
+                <thead id="stats-header">
+                </thead>
+                <tbody id="stats-body">
+                </tbody>
+              </table>
+            </div>
+          </div>
+
+          <div id="hive-job-summary">
+            <h4>Hive Job Summary</h4>
+            <table class="table table-striped table-bordered table-hover" id="hive-table">
+              <thead id="hive-table-header">
+              </thead>
+              <tbody id="hive-table-body">
+              </tbody>
+            </table>
+          </div>
+
+          <div id="job-ids">
+            <h4>Map Reduce Jobs</h4>
+            <table class="table table-striped table-bordered table-hover">
+              <tbody id="job-ids-table-body"></tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+
+#end
+    </div><!-- /.container-full -->
+  </body>
+</html>
diff --git a/az-jobsummary/src/web/js/azkaban/model/log-data.js b/az-jobsummary/src/web/js/azkaban/model/log-data.js
new file mode 100644
index 0000000..2fb9c83
--- /dev/null
+++ b/az-jobsummary/src/web/js/azkaban/model/log-data.js
@@ -0,0 +1,346 @@
+/*
+ * Copyright 2018 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.
+ */
+
+$.namespace('azkaban');
+
+azkaban.LogDataModel = Backbone.Model.extend({
+  TIMESTAMP_REGEX: /^.*? - /gm,
+
+  JOB_TRACKER_URL_REGEX: /https?:\/\/[-\w\.]+(?::\d+)?\/[\w\/\.]*\?\S+(job_\d{12}_\d{4,})\S*/,
+
+  // Command properties
+  COMMAND_START: "Command: ",
+  CLASSPATH_REGEX: /(?:-cp|-classpath)\s+(\S+)/g,
+  ENVIRONMENT_VARIABLES_REGEX: /-D(\S+)/g,
+  JVM_MEMORY_REGEX: /(-Xm\S+)/g,
+  PIG_PARAMS_REGEX: /-param\s+(\S+)/g,
+
+  JOB_TYPE_REGEX: /Building (\S+) job executor/,
+
+  PIG_JOB_SUMMARY_START: "HadoopVersion",
+  PIG_JOB_STATS_START: "Job Stats (time in seconds):",
+
+  HIVE_PARSING_START: "Parsing command: ",
+  HIVE_PARSING_END: "Parse Completed",
+  HIVE_NUM_MAP_REDUCE_JOBS_STRING: "Total MapReduce jobs = ",
+  HIVE_MAP_REDUCE_JOB_START: "Starting Job",
+  HIVE_MAP_REDUCE_JOBS_SUMMARY: "MapReduce Jobs Launched:",
+  HIVE_MAP_REDUCE_SUMMARY_REGEX: /Job (\d+):\s+Map: (\d+)\s+Reduce: (\d+)\s+(?:Cumulative CPU: (.+?))?\s+HDFS Read: (\d+)\s+HDFS Write: (\d+)/,
+
+  JOB_ID_REGEX: /job_\d{12}_\d{4,}/,
+
+  initialize: function() {
+    this.set("offset", 0 );
+    this.set("logData", "");
+    this.on("change:logData", this.parseLogData);
+  },
+
+  refresh: function() {
+    var requestURL = contextURL + "/executor";
+    var finished = false;
+
+    var date = new Date();
+    var startTime = date.getTime();
+
+    while (!finished) {
+      var requestData = {
+        "execid": execId,
+        "jobId": jobId,
+        "ajax":"fetchExecJobLogs",
+        "offset": this.get("offset"),
+        "length": 50000,
+        "attempt": attempt
+      };
+
+      var self = this;
+
+      var successHandler = function(data) {
+        console.log("fetchLogs");
+        if (data.error) {
+          console.log(data.error);
+          finished = true;
+        }
+        else if (data.length == 0) {
+          finished = true;
+        }
+        else {
+          var date = new Date();
+          var endTime = date.getTime();
+          if ((endTime - startTime) > 10000) {
+            finished = true;
+            showDialog("Alert","The log is taking a long time to finish loading. Azkaban has stopped loading them. Please click Refresh to restart the load.");
+          }
+
+          self.set("offset", data.offset + data.length);
+          self.set("logData", self.get("logData") + data.data);
+        }
+      }
+
+      $.ajax({
+        url: requestURL,
+        type: "get",
+        async: false,
+        data: requestData,
+        dataType: "json",
+        error: function(data) {
+          console.log(data);
+          finished = true;
+        },
+        success: successHandler
+      });
+    }
+  },
+
+  parseLogData: function() {
+    var data = this.get("logData").replace(this.TIMESTAMP_REGEX, "");
+    var lines = data.split("\n");
+
+    if (this.parseCommand(lines)) {
+      this.parseJobType(lines);
+      this.parseJobTrackerUrls(lines);
+
+      var jobType = this.get("jobType");
+      if (jobType) {
+        if (jobType.indexOf("pig") !== -1) {
+          this.parsePigTable(lines, "pigSummary", this.PIG_JOB_SUMMARY_START, "", 0);
+          this.parsePigTable(lines, "pigStats", this.PIG_JOB_STATS_START, "", 1);
+        } else if (jobType.indexOf("hive") !== -1) {
+          this.parseHiveQueries(lines);
+        } else {
+          this.parseJobIds(lines);
+        }
+      }
+    }
+  },
+
+  parseCommand: function(lines) {
+    var commandStartIndex = -1;
+    var numLines = lines.length;
+    for (var i = 0; i < numLines; i++) {
+      if (lines[i].indexOf(this.COMMAND_START) === 0) {
+        commandStartIndex = i;
+        break;
+      }
+    }
+
+    if (commandStartIndex != -1) {
+      var commandProperties = {};
+
+      var command = lines[commandStartIndex].substring(this.COMMAND_START.length);
+      commandProperties.Command = command;
+
+      this.parseCommandProperty(command, commandProperties, "Classpath", this.CLASSPATH_REGEX, ':');
+      this.parseCommandProperty(command, commandProperties, "-D", this.ENVIRONMENT_VARIABLES_REGEX);
+      this.parseCommandProperty(command, commandProperties, "Memory Settings", this.JVM_MEMORY_REGEX);
+      this.parseCommandProperty(command, commandProperties, "Params", this.PIG_PARAMS_REGEX);
+
+      this.set("commandProperties", commandProperties);
+
+      return true;
+    }
+
+    return false;
+  },
+
+  parseCommandProperty: function(command, commandProperties, propertyName, regex, split) {
+    var results = [];
+    var match;
+    while (match = regex.exec(command)) {
+      if (split) {
+        results = results.concat(match[1].split(split));
+      } else {
+        results.push(match[1]);
+      }
+    }
+
+    if (results.length > 0) {
+      commandProperties[propertyName] = results;
+    }
+  },
+
+  parseJobTrackerUrls: function(lines) {
+    var jobTrackerUrls = {};
+    var jobTrackerUrlsOrdered = [];
+    var numLines = lines.length;
+    var match;
+    for (var i = 0; i < numLines; i++) {
+      if ((match = this.JOB_TRACKER_URL_REGEX.exec(lines[i])) && !jobTrackerUrls[match[1]]) {
+        jobTrackerUrls[match[1]] = match[0];
+        jobTrackerUrlsOrdered.push(match[0]);
+      }
+    }
+    this.set("jobTrackerUrls", jobTrackerUrls);
+    this.set("jobTrackerUrlsOrdered", jobTrackerUrlsOrdered);
+  },
+
+  parseJobIds: function(lines) {
+    var seenJobIds = {};
+    var jobIds = [];
+    var numLines = lines.length;
+    var match;
+    for (var i = 0; i < numLines; i++) {
+      if ((match = this.JOB_ID_REGEX.exec(lines[i])) && !seenJobIds[match[0]]) {
+        seenJobIds[match[0]] = true;
+        jobIds.push(match[0]);
+      }
+    }
+
+    if (jobIds.length > 0) {
+      this.set("jobIds", jobIds);
+    }
+  },
+
+  parseJobType: function(lines) {
+    var numLines = lines.length;
+    var match;
+    for (var i = 0; i < numLines; i++) {
+      if (match = this.JOB_TYPE_REGEX.exec(lines[i])) {
+        this.set("jobType", match[1]);
+        break;
+      }
+    }
+  },
+
+  parsePigTable: function(lines, tableName, startPattern, endPattern, linesToSkipAfterStart) {
+    var index = -1;
+    var numLines = lines.length;
+    for (var i = 0; i < numLines; i++) {
+      if (lines[i].indexOf(startPattern) === 0) {
+        index = i + linesToSkipAfterStart;
+        break;
+      }
+    }
+
+    if (index != -1) {
+      var table = [];
+      var line;
+      while ((line = lines[index]) !== endPattern) {
+        var columns = line.split("\t");
+        // If first column is a job id, make it a link to the job tracker.
+        if (this.get("jobTrackerUrls")[columns[0]]) {
+          columns[0] = "<a href='" + this.get("jobTrackerUrls")[columns[0]] + "'>" + columns[0] + "</a>";
+        }
+        table.push(columns);
+        index++;
+      }
+
+      this.set(tableName, table);
+    }
+  },
+
+  parseHiveQueries: function(lines) {
+    var hiveQueries = [];
+    var hiveQueryJobs = [];
+
+    var currMapReduceJob = 0;
+    var numLines = lines.length;
+    for (var i = 0; i < numLines;) {
+      var line = lines[i];
+      var parsingCommandIndex = line.indexOf(this.HIVE_PARSING_START);
+      if (parsingCommandIndex === -1) {
+        i++;
+        continue;
+      }
+
+      // parse query text, which could span multiple lines
+      var queryStartIndex = parsingCommandIndex + this.HIVE_PARSING_START.length;
+      var query = line.substring(queryStartIndex) + "<br/>";
+
+      i++;
+      while (i < numLines && (line = lines[i]).indexOf(this.HIVE_PARSING_END) === -1) {
+        query += line + "<br/>";
+        i++;
+      }
+      hiveQueries.push(query);
+      i++;
+
+      // parse the query's Map-Reduce jobs, if any.
+      var numMRJobs = 0;
+      while (i < numLines) {
+        line = lines[i];
+        if (line.indexOf(this.HIVE_NUM_MAP_REDUCE_JOBS_STRING) !== -1) {
+          // query involves map reduce jobs
+          var numMRJobs = parseInt(line.substring(this.HIVE_NUM_MAP_REDUCE_JOBS_STRING.length),10);
+          i++;
+
+          // get the map reduce jobs summary
+          while (i < numLines) {
+            line = lines[i];
+            if (line.indexOf(this.HIVE_MAP_REDUCE_JOBS_SUMMARY) !== -1) {
+              // job summary table found
+              i++;
+
+              var queryJobs = [];
+
+              var previousJob = -1;
+              var numJobsSeen = 0;
+              while (numJobsSeen < numMRJobs && i < numLines) {
+                line = lines[i];
+                var match;
+                if (match = this.HIVE_MAP_REDUCE_SUMMARY_REGEX.exec(line)) {
+                  var currJob = parseInt(match[1], 10);
+                  if (currJob === previousJob) {
+                    i++;
+                    continue;
+                  }
+
+                  var job = [];
+                  job.push("<a href='" + this.get("jobTrackerUrlsOrdered")[currMapReduceJob++] + "'>" + currJob + "</a>");
+                  job.push(match[2]);
+                  job.push(match[3]);
+                  if (match[4]) {
+                    this.set("hasCumulativeCPU", true);
+                    job.push(match[4]);
+                  }
+                  job.push(match[5]);
+                  job.push(match[6]);
+
+                  queryJobs.push(job);
+                  previousJob = currJob;
+                  numJobsSeen++;
+                }
+                i++;
+              }
+
+              if (numJobsSeen === numMRJobs) {
+                hiveQueryJobs.push(queryJobs);
+              }
+
+              break;
+            }
+            i++;
+          }
+          break;
+        }
+        else if (line.indexOf(this.HIVE_PARSING_START) !== -1) {
+          if (numMRJobs === 0) {
+            hiveQueryJobs.push(null);
+          }
+          break;
+        }
+        i++;
+      }
+      continue;
+    }
+
+    if (hiveQueries.length > 0) {
+      this.set("hiveSummary", {
+        hiveQueries: hiveQueries,
+        hiveQueryJobs: hiveQueryJobs
+      });
+    }
+  }
+});
diff --git a/az-jobsummary/src/web/js/azkaban/view/job-summary.js b/az-jobsummary/src/web/js/azkaban/view/job-summary.js
new file mode 100644
index 0000000..f7b14e7
--- /dev/null
+++ b/az-jobsummary/src/web/js/azkaban/view/job-summary.js
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2018 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.
+ */
+
+$.namespace('azkaban');
+
+var jobSummaryView;
+azkaban.JobSummaryView = Backbone.View.extend({
+  events: {
+    "click #update-summary-btn" : "refresh"
+  },
+
+  initialize: function(settings) {
+    $("#job-type").hide();
+    $("#command-summary").hide();
+    $("#pig-job-summary").hide();
+    $("#pig-job-stats").hide();
+    $("#hive-job-summary").hide();
+    $("#job-ids").hide();
+
+    this.listenTo(this.model, "change:jobType", this.renderJobTypeTable);
+    this.listenTo(this.model, "change:commandProperties", this.renderCommandTable);
+    this.listenTo(this.model, "change:pigSummary", this.renderPigSummaryTable);
+    this.listenTo(this.model, "change:pigStats", this.renderPigStatsTable);
+    this.listenTo(this.model, "change:hiveSummary", this.renderHiveTable);
+    this.listenTo(this.model, "change:jobIds", this.renderJobIdsTable);
+  },
+
+  refresh: function() {
+    this.model.refresh();
+  },
+
+  handleUpdate: function(evt) {
+    renderJobTable(jobSummary.summaryTableHeaders, jobSummary.summaryTableData, "summary");
+    renderJobTable(jobSummary.statTableHeaders, jobSummary.statTableData, "stats");
+    renderHiveTable(jobSummary.hiveQueries, jobSummary.hiveQueryJobs);
+  },
+
+  renderJobTypeTable: function() {
+    var jobTypeTable = $("#job-type-table");
+    var jobType = this.model.get("jobType");
+
+    var tr = document.createElement("tr");
+    var td = document.createElement("td");
+    $(td).addClass("property-key");
+    $(td).html("<b>Job Type</b>");
+    $(tr).append(td);
+    td = document.createElement("td");
+    $(td).html(jobType);
+    $(tr).append(td);
+
+    jobTypeTable.append(tr);
+
+    $("#placeholder").hide();
+    $("#job-type").show();
+  },
+
+  renderJobIdsTable: function() {
+    var oldBody = $("#job-ids-table-body");
+    var newBody = $(document.createElement("tbody")).attr("id", "job-ids-table-body");
+
+    var jobIds = this.model.get("jobIds");
+    var jobUrls = this.model.get("jobTrackerUrls");
+    var numJobs = jobIds.length;
+    for (var i = 0; i < numJobs; i++) {
+      var job = jobIds[i];
+      var tr = document.createElement("tr");
+      var td = document.createElement("td");
+      var html = jobUrls[job] ? "<a href='" + jobUrls[job] + "'>" + job + "</a>" : job;
+      $(td).html(html);
+      $(tr).append(td);
+      newBody.append(tr);
+    }
+
+    oldBody.replaceWith(newBody);
+
+    $("#placeholder").hide();
+    $("#job-ids").show();
+  },
+
+  renderCommandTable: function() {
+    var commandTable = $("#command-table");
+    var commandProperties = this.model.get("commandProperties");
+
+    for (var key in commandProperties) {
+      if (commandProperties.hasOwnProperty(key)) {
+        var value = commandProperties[key];
+        if (Array.isArray(value)) {
+          value = value.join("<br/>");
+        }
+        var tr = document.createElement("tr");
+        var keyTd = document.createElement("td");
+        var valueTd = document.createElement("td");
+        $(keyTd).html("<b>" + key + "</b>");
+        $(valueTd).html(value);
+        $(tr).append(keyTd);
+        $(tr).append(valueTd);
+        commandTable.append(tr);
+      }
+    }
+
+    $("#placeholder").hide();
+    $("#command-summary").show();
+  },
+
+  renderPigTable: function(tableName, data) {
+    // Add table headers
+    var header = $("#" + tableName + "-header");
+    var tr = document.createElement("tr");
+    var i;
+    var headers = data[0];
+    var numColumns = headers.length;
+    for (i = 0; i < numColumns; i++) {
+      var th = document.createElement("th");
+      $(th).text(headers[i]);
+      $(tr).append(th);
+    }
+    header.append(tr);
+
+    // Add table body
+    var body = $("#" + tableName + "-body");
+    for (i = 1; i < data.length; i++) {
+      tr = document.createElement("tr");
+      var row = data[i];
+      for (var j = 0; j < numColumns; j++) {
+        var td = document.createElement("td");
+        if (j == 0) {
+          // first column is a link to job details page
+          $(td).html(row[j]);
+        } else {
+          $(td).text(row[j]);
+        }
+        $(tr).append(td);
+      }
+      body.append(tr);
+    }
+
+    $("#placeholder").hide();
+    $("#pig-job-" + tableName).show();
+  },
+
+  renderPigSummaryTable: function() {
+    this.renderPigTable("summary", this.model.get("pigSummary"));
+  },
+
+  renderPigStatsTable: function() {
+    this.renderPigTable("stats", this.model.get("pigStats"));
+  },
+
+  renderHiveTable: function() {
+    var hiveSummary = this.model.get("hiveSummary");
+    var queries = hiveSummary.hiveQueries;
+    var queryJobs = hiveSummary.hiveQueryJobs;
+
+    // Set up table column headers
+    var header = $("#hive-table-header");
+    var tr = document.createElement("tr");
+
+    var headers;
+    if (this.model.get("hasCumulativeCPU")) {
+      headers = ["Query","Job","Map","Reduce","Cumulative CPU","HDFS Read","HDFS Write"];
+    } else {
+      headers = ["Query","Job","Map","Reduce","HDFS Read","HDFS Write"];
+    }
+
+    var i;
+    for (i = 0; i < headers.length; i++) {
+      var th = document.createElement("th");
+      $(th).text(headers[i]);
+      $(tr).append(th);
+    }
+    header.html(tr);
+
+    // Construct table body
+    var oldBody = $("#hive-table-body");
+    var newBody = $(document.createElement("tbody")).attr("id", "hive-table-body");
+    for (i = 0; i < queries.length; i++) {
+      // new query
+      tr = document.createElement("tr");
+      var td = document.createElement("td");
+      $(td).html("<b>" + queries[i] + "</b>");
+      $(tr).append(td);
+
+      var jobs = queryJobs[i];
+      if (jobs != null) {
+        // add first job for this query
+        var jobValues = jobs[0];
+        var j;
+        for (j = 0; j < jobValues.length; j++) {
+          td = document.createElement("td");
+          $(td).html(jobValues[j]);
+          $(tr).append(td);
+        }
+        newBody.append(tr);
+
+        // add remaining jobs for this query
+        for (j = 1; j < jobs.length; j++) {
+          jobValues = jobs[j];
+          tr = document.createElement("tr");
+
+          // add empty cell for query column
+          td = document.createElement("td");
+          $(td).html("&nbsp;");
+          $(tr).append(td);
+
+          // add job values
+          for (var k = 0; k < jobValues.length; k++) {
+            td = document.createElement("td");
+            $(td).html(jobValues[k]);
+            $(tr).append(td);
+          }
+          newBody.append(tr);
+        }
+
+      } else {
+        newBody.append(tr);
+      }
+    }
+    oldBody.replaceWith(newBody);
+
+    $("#placeholder").hide();
+    $("#hive-job-summary").show();
+  }
+});
+
+$(function() {
+  var logDataModel = new azkaban.LogDataModel();
+  jobSummaryView = new azkaban.JobSummaryView({
+    el: $('#job-summary-view'),
+    model: logDataModel
+  });
+  logDataModel.refresh();
+});

settings.gradle 1(+1 -0)

diff --git a/settings.gradle b/settings.gradle
index 9a94fe6..43383a8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -29,4 +29,5 @@ include 'az-flow-trigger-dependency-plugin'
 include 'test'
 include 'az-reportal'
 include 'az-hadoop-jobtype-plugin'
+include 'az-jobsummary'
 include 'az-hdfs-viewer'