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) "▾" else "▴" // UP or DOWN 561 562 <th class={cssClass}> 563 <a href={headerLink}> 564 {header}<span> 565 {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