[task scheduler] Add job timeline page

Change-Id: Ia7376c3991fc400a23315268ef44257143978c26
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/217383
Commit-Queue: Eric Boren <borenet@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/task_scheduler/elements.html b/task_scheduler/elements.html
index 643ef75..7284af7 100644
--- a/task_scheduler/elements.html
+++ b/task_scheduler/elements.html
@@ -16,6 +16,7 @@
 
     <link rel="import" href="/res/imp/job-sk.html" />
     <link rel="import" href="/res/imp/job-search-sk.html" />
+    <link rel="import" href="/res/imp/job-timeline-sk.html" />
     <link rel="import" href="/res/imp/job-trigger-sk.html" />
     <link rel="import" href="/res/imp/task-scheduler-blacklist-sk.html" />
     <link rel="import" href="/res/imp/task-scheduler-status-sk.html" />
diff --git a/task_scheduler/go/task_scheduler/main.go b/task_scheduler/go/task_scheduler/main.go
index 85cb7e3..f711925 100644
--- a/task_scheduler/go/task_scheduler/main.go
+++ b/task_scheduler/go/task_scheduler/main.go
@@ -72,12 +72,13 @@
 	repos repograph.Map
 
 	// HTML templates.
-	blacklistTemplate *template.Template = nil
-	jobTemplate       *template.Template = nil
-	jobSearchTemplate *template.Template = nil
-	mainTemplate      *template.Template = nil
-	taskTemplate      *template.Template = nil
-	triggerTemplate   *template.Template = nil
+	blacklistTemplate   *template.Template = nil
+	jobTemplate         *template.Template = nil
+	jobSearchTemplate   *template.Template = nil
+	jobTimelineTemplate *template.Template = nil
+	mainTemplate        *template.Template = nil
+	taskTemplate        *template.Template = nil
+	triggerTemplate     *template.Template = nil
 
 	// Flags.
 	btInstance        = flag.String("bigtable_instance", "", "BigTable instance to use.")
@@ -136,6 +137,11 @@
 		filepath.Join(*resourcesDir, "templates/header.html"),
 		filepath.Join(*resourcesDir, "templates/footer.html"),
 	))
+	jobTimelineTemplate = template.Must(template.ParseFiles(
+		filepath.Join(*resourcesDir, "templates/job_timeline.html"),
+		filepath.Join(*resourcesDir, "templates/header.html"),
+		filepath.Join(*resourcesDir, "templates/footer.html"),
+	))
 	mainTemplate = template.Must(template.ParseFiles(
 		filepath.Join(*resourcesDir, "templates/main.html"),
 		filepath.Join(*resourcesDir, "templates/header.html"),
@@ -386,6 +392,59 @@
 	}
 }
 
+func jobTimelineHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/html")
+
+	// Don't use cached templates in testing mode.
+	if *local {
+		reloadTemplates()
+	}
+
+	jobId, ok := mux.Vars(r)["id"]
+	if !ok {
+		httputils.ReportError(w, r, nil, "Job ID is required.")
+		return
+	}
+
+	job, err := tsDb.GetJobById(jobId)
+	if err != nil {
+		httputils.ReportError(w, r, err, "Failed to retrieve Job.")
+		return
+	}
+	var tasks = make([]*types.Task, 0, len(job.Tasks))
+	for _, summaries := range job.Tasks {
+		for _, t := range summaries {
+			task, err := tsDb.GetTaskById(t.Id)
+			if err != nil {
+				httputils.ReportError(w, r, err, "Failed to retrieve Task.")
+				return
+			}
+			tasks = append(tasks, task)
+		}
+	}
+	enc, err := json.Marshal(&struct {
+		Job    *types.Job    `json:"job"`
+		Tasks  []*types.Task `json:"tasks"`
+		Epochs []time.Time   `json:"epochs"`
+	}{
+		Job:    job,
+		Tasks:  tasks,
+		Epochs: []time.Time{}, // TODO(borenet): Record tick timestamps.
+	})
+	if err != nil {
+		httputils.ReportError(w, r, err, "Failed to encode JSON.")
+		return
+	}
+	if err := jobTimelineTemplate.Execute(w, struct {
+		Data string
+	}{
+		Data: string(enc),
+	}); err != nil {
+		httputils.ReportError(w, r, err, "Failed to execute template.")
+		return
+	}
+}
+
 func taskHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/html")
 
@@ -502,6 +561,7 @@
 	r.HandleFunc("/", mainHandler)
 	r.HandleFunc("/blacklist", blacklistHandler)
 	r.HandleFunc("/job/{id}", jobHandler)
+	r.HandleFunc("/job/{id}/timeline", jobTimelineHandler)
 	r.HandleFunc("/jobs/search", jobSearchHandler)
 	r.HandleFunc("/task/{id}", taskHandler)
 	r.HandleFunc("/trigger", triggerHandler)
diff --git a/task_scheduler/res/imp/job-timeline-sk-demo.html b/task_scheduler/res/imp/job-timeline-sk-demo.html
index cd4250e..59c3b6e 100644
--- a/task_scheduler/res/imp/job-timeline-sk-demo.html
+++ b/task_scheduler/res/imp/job-timeline-sk-demo.html
@@ -71,7 +71,12 @@
   </script>
   <link rel="import" href="job-timeline-sk.html">
   <link rel="import" href="/res/common/imp/error-toast-sk.html">
-  <link rel="import" href="/res/imp/bower_components/paper-toggle-button/paper-toggle-button.html" />
+  <style>
+  body {
+    display: flex;
+    flex-direction: column;
+  }
+  </style>
 </head>
 <body>
   <h1>job-timeline-sk demo</h1>
diff --git a/task_scheduler/res/imp/job-timeline-sk.html b/task_scheduler/res/imp/job-timeline-sk.html
index 7dfdb2d..5a0f88c 100644
--- a/task_scheduler/res/imp/job-timeline-sk.html
+++ b/task_scheduler/res/imp/job-timeline-sk.html
@@ -28,8 +28,22 @@
 <link rel="import" href="/res/imp/bower_components/polymer/polymer.html">
 <dom-module id="job-timeline-sk">
   <template>
+    <style>
+    :host{
+      flex-grow: 1;
+      display: flex;
+      flex-direction: column;
+    }
+    #svg {
+      flex-grow: 1;
+      min-width: 600px;
+      min-height: 300px;
+      max-width: 1800px;
+      max-height: 800px;
+    }
+    </style>
     <h2>Job <span>[[job.id]]</span></h2>
-    <svg id="svg" width="60%" height="60%"></svg>
+    <svg id="svg"></svg>
   </template>
   <script src="/res/imp/bower_components/d3/d3.min.js"></script>
   <script src="/res/js/gantt.js"></script>
@@ -57,9 +71,10 @@
         "_draw(job.*, tasks.*, epochs.*)",
       ],
 
-      ready: function() {
+      attached: function() {
         this._chart = gantt(this.$.svg);
         window.addEventListener("resize", this.draw.bind(this));
+        this.draw();
       },
 
       draw: function() {
@@ -69,7 +84,7 @@
       },
 
       _draw: function() {
-        if (!this._chart || !this.job || !this.tasks || this.epochs === undefined) {
+        if (!this.job || !this.tasks || this.epochs === undefined || !this._chart) {
           return;
         }
         var tasks = [{
diff --git a/task_scheduler/res/js/gantt.js b/task_scheduler/res/js/gantt.js
index bf96c5a..2f19405 100644
--- a/task_scheduler/res/js/gantt.js
+++ b/task_scheduler/res/js/gantt.js
@@ -57,8 +57,9 @@
     }
 
     // Calculate label offset.
-    const totalWidth = this._svg.getBoundingClientRect().width;
-    const totalHeight = this._svg.getBoundingClientRect().height;
+    const boundingRect = this._svg.getBoundingClientRect();
+    const totalWidth = boundingRect.width;
+    const totalHeight = boundingRect.height;
     const chartMarginLeft = 5;
     const chartMarginRight = 110;
     const chartMarginY = 5;
@@ -279,9 +280,7 @@
     // event.
     this._layoutGetMouseX = function(e) {
       // Convert event x-coordinate to a coordinate within the chart area.
-      // TODO(borenet): Without subtracting 10px here, x is on the right side of
-      // the cursor. Why?
-      let x = e.clientX - 10;
+      let x = e.clientX - boundingRect.x;
       if (x < blockStartX) {
         x = blockStartX;
       } else if (x > totalWidth - chartMarginRight) {
@@ -398,7 +397,7 @@
     };
 
     // Set the mouse line location.
-    if (this._layoutMouseLine) {
+    if (this._layoutMouseLine && this._layoutMouseLine.length > 0) {
       this._layoutMouseLine[0].y2 = blockStartY + blocksHeight;
     } else {
       this._layoutMouseLine = [];
@@ -406,12 +405,12 @@
     this._layoutMouseTime = this._layoutMouseTime || [];
 
     // Set the layout selection box location.
-    if (this._layoutSelectBox) {
+    if (this._layoutSelectBox && this._layoutSelectBox.length > 0) {
       this._layoutSelectBox[0].height = blocksHeight;
     } else {
       this._layoutSelectBox = [];
     }
-    if (this._layoutSelectedTimeRange) {
+    if (this._layoutSelectedTimeRange && this._layoutSelectedTimeRange.length > 0) {
       this._layoutSelectedTimeRange[0].y1 = blockStartY - 10;
       this._layoutSelectedTimeRange[0].y2 = blockStartY;
     } else {
@@ -600,7 +599,9 @@
    * Handler for mousemove events.
    */
   rv._mouseMove = function(e) {
-    this._layoutUpdateMouse(e);
+    if (this._layoutUpdateMouse) {
+      this._layoutUpdateMouse(e);
+    }
   };
   rv._svg.addEventListener('mousemove', rv._mouseMove.bind(rv));
 
@@ -608,7 +609,9 @@
    * Handler for mousedown events.
    */
   rv._mouseDown = function(e) {
-    this._layoutStartSelection(e);
+    if (this._layoutStartSelection) {
+      this._layoutStartSelection(e);
+    }
   };
   rv._svg.addEventListener('mousedown', rv._mouseDown.bind(rv));
 
diff --git a/task_scheduler/templates/job_timeline.html b/task_scheduler/templates/job_timeline.html
new file mode 100644
index 0000000..462c421
--- /dev/null
+++ b/task_scheduler/templates/job_timeline.html
@@ -0,0 +1,13 @@
+{{template "header.html"}}
+
+<job-timeline-sk id="timeline" class="fit"></job-timeline-sk>
+<error-toast-sk></error-toast-sk>
+<script>
+// Add information from the server to the job-timeline-sk.
+var ele = document.getElementById("timeline");
+var data = JSON.parse("{{.Data}}");
+ele.job = data.job;
+ele.tasks = data.tasks;
+ele.epochs = data.epochs;
+</script>
+{{template "footer.html"}}