1/*
2 * Licensed to the Apache Software Foundation (ASF) under one or more
3 * contributor license agreements.  See the NOTICE file distributed with
4 * this work for additional information regarding copyright ownership.
5 * The ASF licenses this file to You under the Apache License, Version 2.0
6 * (the "License"); you may not use this file except in compliance with
7 * the License.  You may obtain a copy of the License at
8 *
9 *    http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package org.apache.spark.ui.jobs
19
20import java.net.URLEncoder
21import java.util.Date
22import javax.servlet.http.HttpServletRequest
23
24import scala.collection.JavaConverters._
25import scala.collection.mutable.{HashMap, ListBuffer}
26import scala.xml._
27
28import org.apache.commons.lang3.StringEscapeUtils
29
30import org.apache.spark.JobExecutionStatus
31import org.apache.spark.scheduler._
32import org.apache.spark.ui._
33import org.apache.spark.ui.jobs.UIData.{JobUIData, StageUIData}
34import org.apache.spark.util.Utils
35
36/** Page showing list of all ongoing and recently finished jobs */
37private[ui] class AllJobsPage(parent: JobsTab) extends WebUIPage("") {
38  private val JOBS_LEGEND =
39    <div class="legend-area"><svg width="150px" height="85px">
40      <rect class="succeeded-job-legend"
41        x="5px" y="5px" width="20px" height="15px" rx="2px" ry="2px"></rect>
42      <text x="35px" y="17px">Succeeded</text>
43      <rect class="failed-job-legend"
44        x="5px" y="30px" width="20px" height="15px" rx="2px" ry="2px"></rect>
45      <text x="35px" y="42px">Failed</text>
46      <rect class="running-job-legend"
47        x="5px" y="55px" width="20px" height="15px" rx="2px" ry="2px"></rect>
48      <text x="35px" y="67px">Running</text>
49    </svg></div>.toString.filter(_ != '\n')
50
51  private val EXECUTORS_LEGEND =
52    <div class="legend-area"><svg width="150px" height="55px">
53      <rect class="executor-added-legend"
54        x="5px" y="5px" width="20px" height="15px" rx="2px" ry="2px"></rect>
55      <text x="35px" y="17px">Added</text>
56      <rect class="executor-removed-legend"
57        x="5px" y="30px" width="20px" height="15px" rx="2px" ry="2px"></rect>
58      <text x="35px" y="42px">Removed</text>
59    </svg></div>.toString.filter(_ != '\n')
60
61  private def getLastStageNameAndDescription(job: JobUIData): (String, String) = {
62    val lastStageInfo = Option(job.stageIds)
63      .filter(_.nonEmpty)
64      .flatMap { ids => parent.jobProgresslistener.stageIdToInfo.get(ids.max)}
65    val lastStageData = lastStageInfo.flatMap { s =>
66      parent.jobProgresslistener.stageIdToData.get((s.stageId, s.attemptId))
67    }
68    val name = lastStageInfo.map(_.name).getOrElse("(Unknown Stage Name)")
69    val description = lastStageData.flatMap(_.description).getOrElse("")
70    (name, description)
71  }
72
73  private def makeJobEvent(jobUIDatas: Seq[JobUIData]): Seq[String] = {
74    jobUIDatas.filter { jobUIData =>
75      jobUIData.status != JobExecutionStatus.UNKNOWN && jobUIData.submissionTime.isDefined
76    }.map { jobUIData =>
77      val jobId = jobUIData.jobId
78      val status = jobUIData.status
79      val (jobName, jobDescription) = getLastStageNameAndDescription(jobUIData)
80      val displayJobDescription =
81        if (jobDescription.isEmpty) {
82          jobName
83        } else {
84          UIUtils.makeDescription(jobDescription, "", plainText = true).text
85        }
86      val submissionTime = jobUIData.submissionTime.get
87      val completionTimeOpt = jobUIData.completionTime
88      val completionTime = completionTimeOpt.getOrElse(System.currentTimeMillis())
89      val classNameByStatus = status match {
90        case JobExecutionStatus.SUCCEEDED => "succeeded"
91        case JobExecutionStatus.FAILED => "failed"
92        case JobExecutionStatus.RUNNING => "running"
93        case JobExecutionStatus.UNKNOWN => "unknown"
94      }
95
96      // The timeline library treats contents as HTML, so we have to escape them. We need to add
97      // extra layers of escaping in order to embed this in a Javascript string literal.
98      val escapedDesc = Utility.escape(displayJobDescription)
99      val jsEscapedDesc = StringEscapeUtils.escapeEcmaScript(escapedDesc)
100      val jobEventJsonAsStr =
101        s"""
102           |{
103           |  'className': 'job application-timeline-object ${classNameByStatus}',
104           |  'group': 'jobs',
105           |  'start': new Date(${submissionTime}),
106           |  'end': new Date(${completionTime}),
107           |  'content': '<div class="application-timeline-content"' +
108           |     'data-html="true" data-placement="top" data-toggle="tooltip"' +
109           |     'data-title="${jsEscapedDesc} (Job ${jobId})<br>' +
110           |     'Status: ${status}<br>' +
111           |     'Submitted: ${UIUtils.formatDate(new Date(submissionTime))}' +
112           |     '${
113                     if (status != JobExecutionStatus.RUNNING) {
114                       s"""<br>Completed: ${UIUtils.formatDate(new Date(completionTime))}"""
115                     } else {
116                       ""
117                     }
118                  }">' +
119           |    '${jsEscapedDesc} (Job ${jobId})</div>'
120           |}
121         """.stripMargin
122      jobEventJsonAsStr
123    }
124  }
125
126  private def makeExecutorEvent(executorUIDatas: Seq[SparkListenerEvent]):
127      Seq[String] = {
128    val events = ListBuffer[String]()
129    executorUIDatas.foreach {
130      case a: SparkListenerExecutorAdded =>
131        val addedEvent =
132          s"""
133             |{
134             |  'className': 'executor added',
135             |  'group': 'executors',
136             |  'start': new Date(${a.time}),
137             |  'content': '<div class="executor-event-content"' +
138             |    'data-toggle="tooltip" data-placement="bottom"' +
139             |    'data-title="Executor ${a.executorId}<br>' +
140             |    'Added at ${UIUtils.formatDate(new Date(a.time))}"' +
141             |    'data-html="true">Executor ${a.executorId} added</div>'
142             |}
143           """.stripMargin
144        events += addedEvent
145      case e: SparkListenerExecutorRemoved =>
146        val removedEvent =
147          s"""
148             |{
149             |  'className': 'executor removed',
150             |  'group': 'executors',
151             |  'start': new Date(${e.time}),
152             |  'content': '<div class="executor-event-content"' +
153             |    'data-toggle="tooltip" data-placement="bottom"' +
154             |    'data-title="Executor ${e.executorId}<br>' +
155             |    'Removed at ${UIUtils.formatDate(new Date(e.time))}' +
156             |    '${
157                      if (e.reason != null) {
158                        s"""<br>Reason: ${e.reason.replace("\n", " ")}"""
159                      } else {
160                        ""
161                      }
162                   }"' +
163             |    'data-html="true">Executor ${e.executorId} removed</div>'
164             |}
165           """.stripMargin
166        events += removedEvent
167
168    }
169    events.toSeq
170  }
171
172  private def makeTimeline(
173      jobs: Seq[JobUIData],
174      executors: Seq[SparkListenerEvent],
175      startTime: Long): Seq[Node] = {
176
177    val jobEventJsonAsStrSeq = makeJobEvent(jobs)
178    val executorEventJsonAsStrSeq = makeExecutorEvent(executors)
179
180    val groupJsonArrayAsStr =
181      s"""
182          |[
183          |  {
184          |    'id': 'executors',
185          |    'content': '<div>Executors</div>${EXECUTORS_LEGEND}',
186          |  },
187          |  {
188          |    'id': 'jobs',
189          |    'content': '<div>Jobs</div>${JOBS_LEGEND}',
190          |  }
191          |]
192        """.stripMargin
193
194    val eventArrayAsStr =
195      (jobEventJsonAsStrSeq ++ executorEventJsonAsStrSeq).mkString("[", ",", "]")
196
197    <span class="expand-application-timeline">
198      <span class="expand-application-timeline-arrow arrow-closed"></span>
199      <a data-toggle="tooltip" title={ToolTips.JOB_TIMELINE} data-placement="right">
200        Event Timeline
201      </a>
202    </span> ++
203    <div id="application-timeline" class="collapsed">
204      <div class="control-panel">
205        <div id="application-timeline-zoom-lock">
206          <input type="checkbox"></input>
207          <span>Enable zooming</span>
208        </div>
209      </div>
210    </div> ++
211    <script type="text/javascript">
212      {Unparsed(s"drawApplicationTimeline(${groupJsonArrayAsStr}," +
213      s"${eventArrayAsStr}, ${startTime}, ${UIUtils.getTimeZoneOffset()});")}
214    </script>
215  }
216
217  private def jobsTable(
218      request: HttpServletRequest,
219      tableHeaderId: String,
220      jobTag: String,
221      jobs: Seq[JobUIData],
222      killEnabled: Boolean): Seq[Node] = {
223    val allParameters = request.getParameterMap.asScala.toMap
224    val parameterOtherTable = allParameters.filterNot(_._1.startsWith(jobTag))
225      .map(para => para._1 + "=" + para._2(0))
226
227    val someJobHasJobGroup = jobs.exists(_.jobGroup.isDefined)
228    val jobIdTitle = if (someJobHasJobGroup) "Job Id (Job Group)" else "Job Id"
229
230    val parameterJobPage = request.getParameter(jobTag + ".page")
231    val parameterJobSortColumn = request.getParameter(jobTag + ".sort")
232    val parameterJobSortDesc = request.getParameter(jobTag + ".desc")
233    val parameterJobPageSize = request.getParameter(jobTag + ".pageSize")
234    val parameterJobPrevPageSize = request.getParameter(jobTag + ".prevPageSize")
235
236    val jobPage = Option(parameterJobPage).map(_.toInt).getOrElse(1)
237    val jobSortColumn = Option(parameterJobSortColumn).map { sortColumn =>
238      UIUtils.decodeURLParameter(sortColumn)
239    }.getOrElse(jobIdTitle)
240    val jobSortDesc = Option(parameterJobSortDesc).map(_.toBoolean).getOrElse(
241      // New jobs should be shown above old jobs by default.
242      if (jobSortColumn == jobIdTitle) true else false
243    )
244    val jobPageSize = Option(parameterJobPageSize).map(_.toInt).getOrElse(100)
245    val jobPrevPageSize = Option(parameterJobPrevPageSize).map(_.toInt).getOrElse(jobPageSize)
246
247    val page: Int = {
248      // If the user has changed to a larger page size, then go to page 1 in order to avoid
249      // IndexOutOfBoundsException.
250      if (jobPageSize <= jobPrevPageSize) {
251        jobPage
252      } else {
253        1
254      }
255    }
256    val currentTime = System.currentTimeMillis()
257
258    try {
259      new JobPagedTable(
260        jobs,
261        tableHeaderId,
262        jobTag,
263        UIUtils.prependBaseUri(parent.basePath),
264        "jobs", // subPath
265        parameterOtherTable,
266        parent.jobProgresslistener.stageIdToInfo,
267        parent.jobProgresslistener.stageIdToData,
268        killEnabled,
269        currentTime,
270        jobIdTitle,
271        pageSize = jobPageSize,
272        sortColumn = jobSortColumn,
273        desc = jobSortDesc
274      ).table(page)
275    } catch {
276      case e @ (_ : IllegalArgumentException | _ : IndexOutOfBoundsException) =>
277        <div class="alert alert-error">
278          <p>Error while rendering job table:</p>
279          <pre>
280            {Utils.exceptionString(e)}
281          </pre>
282        </div>
283    }
284  }
285
286  def render(request: HttpServletRequest): Seq[Node] = {
287    val listener = parent.jobProgresslistener
288    listener.synchronized {
289      val startTime = listener.startTime
290      val endTime = listener.endTime
291      val activeJobs = listener.activeJobs.values.toSeq
292      val completedJobs = listener.completedJobs.reverse.toSeq
293      val failedJobs = listener.failedJobs.reverse.toSeq
294
295      val activeJobsTable =
296        jobsTable(request, "active", "activeJob", activeJobs, killEnabled = parent.killEnabled)
297      val completedJobsTable =
298        jobsTable(request, "completed", "completedJob", completedJobs, killEnabled = false)
299      val failedJobsTable =
300        jobsTable(request, "failed", "failedJob", failedJobs, killEnabled = false)
301
302      val shouldShowActiveJobs = activeJobs.nonEmpty
303      val shouldShowCompletedJobs = completedJobs.nonEmpty
304      val shouldShowFailedJobs = failedJobs.nonEmpty
305
306      val completedJobNumStr = if (completedJobs.size == listener.numCompletedJobs) {
307        s"${completedJobs.size}"
308      } else {
309        s"${listener.numCompletedJobs}, only showing ${completedJobs.size}"
310      }
311
312      val summary: NodeSeq =
313        <div>
314          <ul class="unstyled">
315            <li>
316              <strong>User:</strong>
317              {parent.getSparkUser}
318            </li>
319            <li>
320              <strong>Total Uptime:</strong>
321              {
322                if (endTime < 0 && parent.sc.isDefined) {
323                  UIUtils.formatDuration(System.currentTimeMillis() - startTime)
324                } else if (endTime > 0) {
325                  UIUtils.formatDuration(endTime - startTime)
326                }
327              }
328            </li>
329            <li>
330              <strong>Scheduling Mode: </strong>
331              {listener.schedulingMode.map(_.toString).getOrElse("Unknown")}
332            </li>
333            {
334              if (shouldShowActiveJobs) {
335                <li>
336                  <a href="#active"><strong>Active Jobs:</strong></a>
337                  {activeJobs.size}
338                </li>
339              }
340            }
341            {
342              if (shouldShowCompletedJobs) {
343                <li id="completed-summary">
344                  <a href="#completed"><strong>Completed Jobs:</strong></a>
345                  {completedJobNumStr}
346                </li>
347              }
348            }
349            {
350              if (shouldShowFailedJobs) {
351                <li>
352                  <a href="#failed"><strong>Failed Jobs:</strong></a>
353                  {listener.numFailedJobs}
354                </li>
355              }
356            }
357          </ul>
358        </div>
359
360      var content = summary
361      val executorListener = parent.executorListener
362      content ++= makeTimeline(activeJobs ++ completedJobs ++ failedJobs,
363          executorListener.executorEvents, startTime)
364
365      if (shouldShowActiveJobs) {
366        content ++= <h4 id="active">Active Jobs ({activeJobs.size})</h4> ++
367          activeJobsTable
368      }
369      if (shouldShowCompletedJobs) {
370        content ++= <h4 id="completed">Completed Jobs ({completedJobNumStr})</h4> ++
371          completedJobsTable
372      }
373      if (shouldShowFailedJobs) {
374        content ++= <h4 id ="failed">Failed Jobs ({failedJobs.size})</h4> ++
375          failedJobsTable
376      }
377
378      val helpText = """A job is triggered by an action, like count() or saveAsTextFile().""" +
379        " Click on a job to see information about the stages of tasks inside it."
380
381      UIUtils.headerSparkPage("Spark Jobs", content, parent, helpText = Some(helpText))
382    }
383  }
384}
385
386private[ui] class JobTableRowData(
387    val jobData: JobUIData,
388    val lastStageName: String,
389    val lastStageDescription: String,
390    val duration: Long,
391    val formattedDuration: String,
392    val submissionTime: Long,
393    val formattedSubmissionTime: String,
394    val jobDescription: NodeSeq,
395    val detailUrl: String)
396
397private[ui] class JobDataSource(
398    jobs: Seq[JobUIData],
399    stageIdToInfo: HashMap[Int, StageInfo],
400    stageIdToData: HashMap[(Int, Int), StageUIData],
401    basePath: String,
402    currentTime: Long,
403    pageSize: Int,
404    sortColumn: String,
405    desc: Boolean) extends PagedDataSource[JobTableRowData](pageSize) {
406
407  // Convert JobUIData to JobTableRowData which contains the final contents to show in the table
408  // so that we can avoid creating duplicate contents during sorting the data
409  private val data = jobs.map(jobRow).sorted(ordering(sortColumn, desc))
410
411  private var _slicedJobIds: Set[Int] = null
412
413  override def dataSize: Int = data.size
414
415  override def sliceData(from: Int, to: Int): Seq[JobTableRowData] = {
416    val r = data.slice(from, to)
417    _slicedJobIds = r.map(_.jobData.jobId).toSet
418    r
419  }
420
421  private def getLastStageNameAndDescription(job: JobUIData): (String, String) = {
422    val lastStageInfo = Option(job.stageIds)
423      .filter(_.nonEmpty)
424      .flatMap { ids => stageIdToInfo.get(ids.max)}
425    val lastStageData = lastStageInfo.flatMap { s =>
426      stageIdToData.get((s.stageId, s.attemptId))
427    }
428    val name = lastStageInfo.map(_.name).getOrElse("(Unknown Stage Name)")
429    val description = lastStageData.flatMap(_.description).getOrElse("")
430    (name, description)
431  }
432
433  private def jobRow(jobData: JobUIData): JobTableRowData = {
434    val (lastStageName, lastStageDescription) = getLastStageNameAndDescription(jobData)
435    val duration: Option[Long] = {
436      jobData.submissionTime.map { start =>
437        val end = jobData.completionTime.getOrElse(System.currentTimeMillis())
438        end - start
439      }
440    }
441    val formattedDuration = duration.map(d => UIUtils.formatDuration(d)).getOrElse("Unknown")
442    val submissionTime = jobData.submissionTime
443    val formattedSubmissionTime = submissionTime.map(UIUtils.formatDate).getOrElse("Unknown")
444    val jobDescription = UIUtils.makeDescription(lastStageDescription, basePath, plainText = false)
445
446    val detailUrl = "%s/jobs/job?id=%s".format(basePath, jobData.jobId)
447
448    new JobTableRowData (
449      jobData,
450      lastStageName,
451      lastStageDescription,
452      duration.getOrElse(-1),
453      formattedDuration,
454      submissionTime.getOrElse(-1),
455      formattedSubmissionTime,
456      jobDescription,
457      detailUrl
458    )
459  }
460
461  /**
462   * Return Ordering according to sortColumn and desc
463   */
464  private def ordering(sortColumn: String, desc: Boolean): Ordering[JobTableRowData] = {
465    val ordering: Ordering[JobTableRowData] = sortColumn match {
466      case "Job Id" | "Job Id (Job Group)" => Ordering.by(_.jobData.jobId)
467      case "Description" => Ordering.by(x => (x.lastStageDescription, x.lastStageName))
468      case "Submitted" => Ordering.by(_.submissionTime)
469      case "Duration" => Ordering.by(_.duration)
470      case "Stages: Succeeded/Total" | "Tasks (for all stages): Succeeded/Total" =>
471        throw new IllegalArgumentException(s"Unsortable column: $sortColumn")
472      case unknownColumn => throw new IllegalArgumentException(s"Unknown column: $unknownColumn")
473    }
474    if (desc) {
475      ordering.reverse
476    } else {
477      ordering
478    }
479  }
480
481}
482private[ui] class JobPagedTable(
483    data: Seq[JobUIData],
484    tableHeaderId: String,
485    jobTag: String,
486    basePath: String,
487    subPath: String,
488    parameterOtherTable: Iterable[String],
489    stageIdToInfo: HashMap[Int, StageInfo],
490    stageIdToData: HashMap[(Int, Int), StageUIData],
491    killEnabled: Boolean,
492    currentTime: Long,
493    jobIdTitle: String,
494    pageSize: Int,
495    sortColumn: String,
496    desc: Boolean
497  ) extends PagedTable[JobTableRowData] {
498  val parameterPath = basePath + s"/$subPath/?" + parameterOtherTable.mkString("&")
499
500  override def tableId: String = jobTag + "-table"
501
502  override def tableCssClass: String =
503    "table table-bordered table-condensed table-striped " +
504      "table-head-clickable table-cell-width-limited"
505
506  override def pageSizeFormField: String = jobTag + ".pageSize"
507
508  override def prevPageSizeFormField: String = jobTag + ".prevPageSize"
509
510  override def pageNumberFormField: String = jobTag + ".page"
511
512  override val dataSource = new JobDataSource(
513    data,
514    stageIdToInfo,
515    stageIdToData,
516    basePath,
517    currentTime,
518    pageSize,
519    sortColumn,
520    desc)
521
522  override def pageLink(page: Int): String = {
523    val encodedSortColumn = URLEncoder.encode(sortColumn, "UTF-8")
524    parameterPath +
525      s"&$pageNumberFormField=$page" +
526      s"&$jobTag.sort=$encodedSortColumn" +
527      s"&$jobTag.desc=$desc" +
528      s"&$pageSizeFormField=$pageSize" +
529      s"#$tableHeaderId"
530  }
531
532  override def goButtonFormPath: String = {
533    val encodedSortColumn = URLEncoder.encode(sortColumn, "UTF-8")
534    s"$parameterPath&$jobTag.sort=$encodedSortColumn&$jobTag.desc=$desc#$tableHeaderId"
535  }
536
537  override def headers: Seq[Node] = {
538    // Information for each header: title, cssClass, and sortable
539    val jobHeadersAndCssClasses: Seq[(String, String, Boolean)] =
540      Seq(
541        (jobIdTitle, "", true),
542        ("Description", "", true), ("Submitted", "", true), ("Duration", "", true),
543        ("Stages: Succeeded/Total", "", false),
544        ("Tasks (for all stages): Succeeded/Total", "", false)
545      )
546
547    if (!jobHeadersAndCssClasses.filter(_._3).map(_._1).contains(sortColumn)) {
548      throw new IllegalArgumentException(s"Unknown column: $sortColumn")
549    }
550
551    val headerRow: Seq[Node] = {
552      jobHeadersAndCssClasses.map { case (header, cssClass, sortable) =>
553        if (header == sortColumn) {
554          val headerLink = Unparsed(
555            parameterPath +
556              s"&$jobTag.sort=${URLEncoder.encode(header, "UTF-8")}" +
557              s"&$jobTag.desc=${!desc}" +
558              s"&$jobTag.pageSize=$pageSize" +
559              s"#$tableHeaderId")
560          val arrow = if (desc) "&#x25BE;" else "&#x25B4;" // UP or DOWN
561
562          <th class={cssClass}>
563            <a href={headerLink}>
564              {header}<span>
565              &nbsp;{Unparsed(arrow)}
566            </span>
567            </a>
568          </th>
569        } else {
570          if (sortable) {
571            val headerLink = Unparsed(
572              parameterPath +
573                s"&$jobTag.sort=${URLEncoder.encode(header, "UTF-8")}" +
574                s"&$jobTag.pageSize=$pageSize" +
575                s"#$tableHeaderId")
576
577            <th class={cssClass}>
578              <a href={headerLink}>
579                {header}
580              </a>
581            </th>
582          } else {
583            <th class={cssClass}>
584              {header}
585            </th>
586          }
587        }
588      }
589    }
590    <thead>{headerRow}</thead>
591  }
592
593  override def row(jobTableRow: JobTableRowData): Seq[Node] = {
594    val job = jobTableRow.jobData
595
596    val killLink = if (killEnabled) {
597      val confirm =
598        s"if (window.confirm('Are you sure you want to kill job ${job.jobId} ?')) " +
599          "{ this.parentNode.submit(); return true; } else { return false; }"
600      // SPARK-6846 this should be POST-only but YARN AM won't proxy POST
601      /*
602      val killLinkUri = s"$basePathUri/jobs/job/kill/"
603      <form action={killLinkUri} method="POST" style="display:inline">
604        <input type="hidden" name="id" value={job.jobId.toString}/>
605        <a href="#" onclick={confirm} class="kill-link">(kill)</a>
606      </form>
607       */
608      val killLinkUri = s"$basePath/jobs/job/kill/?id=${job.jobId}"
609      <a href={killLinkUri} onclick={confirm} class="kill-link">(kill)</a>
610    } else {
611      Seq.empty
612    }
613
614    <tr id={"job-" + job.jobId}>
615      <td>
616        {job.jobId} {job.jobGroup.map(id => s"($id)").getOrElse("")}
617      </td>
618      <td>
619        {jobTableRow.jobDescription} {killLink}
620        <a href={jobTableRow.detailUrl} class="name-link">{jobTableRow.lastStageName}</a>
621      </td>
622      <td>
623        {jobTableRow.formattedSubmissionTime}
624      </td>
625      <td>{jobTableRow.formattedDuration}</td>
626      <td class="stage-progress-cell">
627        {job.completedStageIndices.size}/{job.stageIds.size - job.numSkippedStages}
628        {if (job.numFailedStages > 0) s"(${job.numFailedStages} failed)"}
629        {if (job.numSkippedStages > 0) s"(${job.numSkippedStages} skipped)"}
630      </td>
631      <td class="progress-cell">
632        {UIUtils.makeProgressBar(started = job.numActiveTasks, completed = job.numCompletedTasks,
633        failed = job.numFailedTasks, skipped = job.numSkippedTasks, killed = job.numKilledTasks,
634        total = job.numTasks - job.numSkippedTasks)}
635      </td>
636    </tr>
637  }
638}
639