This is an automated email from the ASF dual-hosted git repository.

yao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new e8d8e6a8d040 [SPARK-55971][UI] Add Jobs table to SQL execution detail 
page
e8d8e6a8d040 is described below

commit e8d8e6a8d040d26aae9571e968e0c64bda0875dc
Author: Kent Yao <[email protected]>
AuthorDate: Fri Mar 13 20:58:23 2026 +0800

    [SPARK-55971][UI] Add Jobs table to SQL execution detail page
    
    ### What changes were proposed in this pull request?
    
    1. **Jobs table on SQL execution page** — Replaces the old comma-separated 
job ID links with a full "Associated Jobs" table on the SQL execution detail 
page.
    
    | Column | Description |
    |--------|-------------|
    | Job ID | Link to job detail page |
    | Description | Stage name and description (via 
`ApiHelper.lastStageNameAndDescription`) |
    | Submitted | Submission time (sortable by epoch) |
    | Duration | Human-readable duration (sortable by millis) |
    | Stages: Succeeded/Total | Completed/total with failed/skipped counts |
    | Tasks: Succeeded/Total | Progress bar with task counts |
    
    **Features:**
    - Collapsible section using BS5 Collapse (consistent with Plan Details, SQL 
Properties)
    - Expanded by default, shows job count in header
    - Sortable columns via `sorttable.js` (Stages/Tasks excluded with 
`sorttable_nosort`)
    - Responsive table wrapper for small screens
    - Gracefully handles missing jobs (`NoSuchElementException` catch)
    
    2. **Concise progress bar labels** — The `makeProgressBar` visible label 
now shows `(N killed)` instead of the full kill reason with stack trace. The 
truncated reason (120 chars) is kept in the tooltip for hover inspection. This 
applies globally across Jobs, Stages, and SQL execution pages.
    
    ### Why are the changes needed?
    
    Previously jobs were shown as comma-separated ID links (e.g., "Running 
Jobs: 0 1 2") in the summary section, providing no context about job status, 
duration, or progress. The table shows all relevant information at a glance, 
matching the Jobs page style.
    
    The progress bar kill reason text could be extremely long (full stack 
traces), making the bar unreadable.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes:
    - New "Associated Jobs (N)" collapsible table on the SQL execution detail 
page
    - Progress bar labels across all pages now show concise `(N killed)` 
instead of full error text
    
    ### How was this patch tested?
    
    Compilation verified. Manual testing with succeeded, failed, and 
killed-task jobs.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, co-authored with GitHub Copilot.
    
    Closes #54768 from yaooqinn/SPARK-55971.
    
    Authored-by: Kent Yao <[email protected]>
    Signed-off-by: Kent Yao <[email protected]>
---
 .../main/scala/org/apache/spark/ui/UIUtils.scala   |  11 +-
 .../spark/sql/execution/ui/ExecutionPage.scala     | 111 +++++++++++++++++----
 2 files changed, 97 insertions(+), 25 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala 
b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
index cd2bc6d58075..51b96dd27497 100644
--- a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
+++ b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
@@ -494,8 +494,11 @@ private[spark] object UIUtils extends Logging {
     val startRatio = if (total == 0) 0.0 else (boundedStarted.toDouble / 
total) * 100
     val startWidth = "width: %s%%".format(startRatio)
 
-    val killTaskReasonText = reasonToNumKilled.toSeq.sortBy(-_._2).map {
-        case (reason, count) => s" ($count killed: $reason)"
+    val totalKilled = reasonToNumKilled.values.sum
+    val killReasonTitle = reasonToNumKilled.toSeq.sortBy(-_._2).map {
+        case (reason, count) =>
+          val truncated = if (reason.length > 120) reason.take(120) + "..." 
else reason
+          s" ($count killed: $truncated)"
       }.mkString
     val progressTitle = s"$completed/$total" + {
       if (started > 0) s" ($started running)" else ""
@@ -503,13 +506,13 @@ private[spark] object UIUtils extends Logging {
       if (failed > 0) s" ($failed failed)" else ""
     } + {
       if (skipped > 0) s" ($skipped skipped)" else ""
-    } + killTaskReasonText
+    } + killReasonTitle
 
     val progressLabel = s"$completed/$total" +
       (if (failed == 0 && skipped == 0 && started > 0) s" ($started running)" 
else "") +
       (if (failed > 0) s" ($failed failed)" else "") +
       (if (skipped > 0) s" ($skipped skipped)" else "") +
-      killTaskReasonText
+      (if (totalKilled > 0) s" ($totalKilled killed)" else "")
 
     // scalastyle:off line.size.limit
     <div class="progress-stacked" title={progressTitle}>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
index aefbf8e62cda..4b1b92608fa5 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
@@ -24,10 +24,10 @@ import org.json4s.JNull
 import org.json4s.JsonAST.{JBool, JString}
 import org.json4s.jackson.JsonMethods.parse
 
-import org.apache.spark.JobExecutionStatus
 import org.apache.spark.internal.Logging
 import org.apache.spark.internal.config.UI.UI_SQL_GROUP_SUB_EXECUTION_ENABLED
 import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.ui.jobs.ApiHelper
 
 class ExecutionPage(parent: SQLTab) extends WebUIPage("execution") with 
Logging {
 
@@ -48,23 +48,6 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
       val duration = 
executionUIData.completionTime.map(_.getTime()).getOrElse(currentTime) -
         executionUIData.submissionTime
 
-      def jobLinks(status: JobExecutionStatus, label: String): Seq[Node] = {
-        val jobs = executionUIData.jobs.flatMap { case (jobId, jobStatus) =>
-          if (jobStatus == status) Some(jobId) else None
-        }
-        if (jobs.nonEmpty) {
-          <li class="job-url">
-            <strong>{label} </strong>
-            {jobs.toSeq.sorted.map { jobId =>
-              <a href={jobURL(request, 
jobId.intValue())}>{jobId.toString}</a><span>&nbsp;</span>
-            }}
-          </li>
-        } else {
-          Nil
-        }
-      }
-
-
       val summary =
         <div>
           <ul class="list-unstyled">
@@ -107,9 +90,6 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
                 }
               }
             }
-            {jobLinks(JobExecutionStatus.RUNNING, "Running Jobs:")}
-            {jobLinks(JobExecutionStatus.SUCCEEDED, "Succeeded Jobs:")}
-            {jobLinks(JobExecutionStatus.FAILED, "Failed Jobs:")}
           </ul>
           <div id="plan-viz-download-btn-container">
             <select id="plan-viz-format-select">
@@ -130,6 +110,7 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
       summary ++
         planVisualization(request, metrics, graph) ++
         physicalPlanDescription(executionUIData.physicalPlanDescription) ++
+        jobsTable(request, executionUIData) ++
         modifiedConfigs(configs.filter { case (k, _) => 
!k.startsWith(pandasOnSparkConfPrefix) }) ++
         modifiedPandasOnSparkConfigs(
           configs.filter { case (k, _) => 
k.startsWith(pandasOnSparkConfPrefix) }) ++
@@ -225,6 +206,94 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
     </div>
   }
 
+  private def jobsTable(
+      request: HttpServletRequest,
+      executionUIData: SQLExecutionUIData): Seq[Node] = {
+    val jobIds = executionUIData.jobs.keys.toSeq.sorted.reverse
+    if (jobIds.isEmpty) return Nil
+
+    val store = parent.parent.store
+    val basePath = UIUtils.prependBaseUri(request, parent.basePath)
+    val rows = jobIds.flatMap { jobId =>
+      try {
+        val job = store.job(jobId)
+        val submissionTimeMs = job.submissionTime.map(_.getTime).getOrElse(-1L)
+        val formattedTime = 
job.submissionTime.map(UIUtils.formatDate).getOrElse("")
+        val durationMs = (job.submissionTime, job.completionTime) match {
+          case (Some(start), Some(end)) => end.getTime - start.getTime
+          case (Some(start), None) => System.currentTimeMillis() - 
start.getTime
+          case _ => -1L
+        }
+        val duration = if (durationMs >= 0) UIUtils.formatDuration(durationMs) 
else ""
+        val (lastStageName, lastStageDesc) =
+          ApiHelper.lastStageNameAndDescription(store, job)
+        val jobDesc = UIUtils.makeDescription(
+          job.description.getOrElse(lastStageDesc), basePath, plainText = 
false)
+        val detailUrl = s"$basePath/jobs/job/?id=$jobId"
+        val stagesInfo = {
+          val completed = job.numCompletedStages
+          val total = job.stageIds.size - job.numSkippedStages
+          val extra = Seq(
+            if (job.numFailedStages > 0) s"(${job.numFailedStages} failed)" 
else "",
+            if (job.numSkippedStages > 0) s"(${job.numSkippedStages} skipped)" 
else ""
+          ).filter(_.nonEmpty).mkString(" ")
+          s"$completed/$total $extra"
+        }
+        Some(
+          <tr id={"job-" + jobId}>
+            <td><a href={jobURL(request, jobId)}>{jobId}</a></td>
+            <td>
+              {jobDesc}
+              <a href={detailUrl} class="name-link">{lastStageName}</a>
+            </td>
+            <td 
sorttable_customkey={submissionTimeMs.toString}>{formattedTime}</td>
+            <td sorttable_customkey={durationMs.toString}>{duration}</td>
+            <td class="stage-progress-cell">{stagesInfo}</td>
+            <td class="progress-cell">
+              {UIUtils.makeProgressBar(started = job.numActiveTasks,
+              completed = job.numCompletedIndices,
+              failed = job.numFailedTasks, skipped = job.numSkippedTasks,
+              reasonToNumKilled = job.killedTasksSummary,
+              total = job.numTasks - job.numSkippedTasks)}
+            </td>
+          </tr>)
+      } catch {
+        case _: NoSuchElementException => None
+      }
+    }
+
+    // scalastyle:off
+    <div>
+      <span class="collapse-table" data-bs-toggle="collapse"
+            data-bs-target="#sql-jobs-table"
+            aria-expanded="true" aria-controls="sql-jobs-table"
+            data-collapse-name="collapse-sql-jobs">
+        <h4>
+          <span class="collapse-table-arrow arrow-open"></span>
+          <a>Associated Jobs ({jobIds.size})</a>
+        </h4>
+      </span>
+      <div class="collapsible-table collapse show" id="sql-jobs-table">
+        <div class="table-responsive">
+        <table class="table table-bordered table-hover table-sm sortable">
+          <thead>
+            <tr>
+              <th>Job ID</th>
+              <th>Description</th>
+              <th>Submitted</th>
+              <th>Duration</th>
+              <th class="sorttable_nosort">Stages: Succeeded/Total</th>
+              <th class="sorttable_nosort">Tasks (for all stages): 
Succeeded/Total</th>
+            </tr>
+          </thead>
+          <tbody>{rows}</tbody>
+        </table>
+        </div>
+      </div>
+    </div>
+    // scalastyle:on
+  }
+
   private def modifiedConfigs(modifiedConfigs: Map[String, String]): Seq[Node] 
= {
     if (Option(modifiedConfigs).exists(_.isEmpty)) return Nil
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to