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> </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]