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 210d74414fb2 [SPARK-55837][UI] Render Environment page tables 
client-side via REST API
210d74414fb2 is described below

commit 210d74414fb2f6b06ec1e3b39391791414859a0d
Author: Kent Yao <[email protected]>
AuthorDate: Thu Mar 5 18:12:25 2026 +0800

    [SPARK-55837][UI] Render Environment page tables client-side via REST API
    
    ### What changes were proposed in this pull request?
    
    Switches the Environment page from server-side Scala XML table rendering to 
client-side DataTables populated via the existing REST API endpoint 
(`/api/v1/applications/{appId}/environment`).
    
    **EnvironmentPage.scala:**
    - Remove all `UIUtils.listingTable` calls and server-side data preparation
    - Remove badge counts from tabs (JS adds them dynamically after data loads)
    - Add loading spinner placeholders in each tab pane
    - Include `environmentpage.js` and `utils.js` as ES modules
    - Enable DataTables via `useDataTables = true` in `headerSparkPage`
    
    **environmentpage.js:**
    - Rewrite as ES module importing `getStandAloneAppId` and 
`setDataTableDefaults` from `utils.js`
    - Fetch environment data via REST API on page load
    - Create DataTables with sorting, searching, and pagination for each section
    - Use `deferRender: true` for performance with 1000+ row tables (e.g., 
Hadoop Properties)
    - Format Resource Profiles with structured executor/task resource text
    - Dynamically update badge counts on tabs after data loads
    - Persist active tab state in localStorage
    - Adjust DataTable columns on tab switch for correct rendering in hidden 
panes
    
    ### Why are the changes needed?
    
    This continues the modernization of the Spark UI Environment page started 
in SPARK-55834. Moving to client-side rendering:
    - Reduces server-side HTML payload significantly (especially for 1000+ 
Hadoop properties)
    - Adds built-in search, sort, and pagination via DataTables
    - Follows the same pattern used by the Executors page
    - Improves user experience for environments with many properties
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. The Environment page tables now render client-side with DataTables, 
providing:
    - Built-in search/filter across all columns
    - Column sorting
    - Pagination (important for large property sets)
    - Loading spinners while data fetches
    
    ### How was this patch tested?
    
    - `./dev/lint-js` — passed
    - `./dev/scalastyle` — passed
    - `./build/sbt core/compile` — passed
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, GitHub Copilot.
    
    Closes #54632 from yaooqinn/SPARK-55837.
    
    Authored-by: Kent Yao <[email protected]>
    Signed-off-by: Kent Yao <[email protected]>
---
 .../org/apache/spark/ui/static/environmentpage.js  | 194 ++++++++++++++++-----
 .../org/apache/spark/ui/env/EnvironmentPage.scala  | 109 ++----------
 2 files changed, 172 insertions(+), 131 deletions(-)

diff --git 
a/core/src/main/resources/org/apache/spark/ui/static/environmentpage.js 
b/core/src/main/resources/org/apache/spark/ui/static/environmentpage.js
index 28df9824c9b2..bbfa28b27529 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/environmentpage.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/environmentpage.js
@@ -15,9 +15,86 @@
  * limitations under the License.
  */
 
-/* global $ */
+/* global $, uiRoot */
+
+import { getStandAloneAppId, setDataTableDefaults } from "./utils.js";
+
+function createRESTEndPointForEnvironmentPage(appId) {
+  var words = document.baseURI.split("/");
+  var ind = words.indexOf("proxy");
+  var newBaseURI;
+  if (ind > 0) {
+    appId = words[ind + 1];
+    newBaseURI = words.slice(0, ind + 2).join("/");
+    return newBaseURI + "/api/v1/applications/" + appId + "/environment";
+  }
+  ind = words.indexOf("history");
+  if (ind > 0) {
+    appId = words[ind + 1];
+    var attemptId = words[ind + 2];
+    newBaseURI = words.slice(0, ind).join("/");
+    if (isNaN(attemptId)) {
+      return newBaseURI + "/api/v1/applications/" + appId + "/environment";
+    } else {
+      return newBaseURI + "/api/v1/applications/" + appId + "/" + attemptId + 
"/environment";
+    }
+  }
+  return uiRoot + "/api/v1/applications/" + appId + "/environment";
+}
+
+function updateBadge(tabId, count) {
+  var tab = document.getElementById(tabId);
+  if (tab) {
+    var existing = tab.querySelector(".badge");
+    if (existing) {
+      existing.textContent = count;
+    } else {
+      var badge = document.createElement("span");
+      badge.className = "badge bg-secondary ms-1";
+      badge.textContent = count;
+      tab.appendChild(badge);
+    }
+  }
+}
+
+function formatResourceProfile(rp) {
+  var lines = ["Executor Reqs:"];
+  var execRes = rp.executorResources || {};
+  Object.keys(execRes).sort().forEach(function (key) {
+    var r = execRes[key];
+    var s = "\t" + r.resourceName + ": [amount: " + r.amount;
+    if (r.discoveryScript) { s += ", discovery: " + r.discoveryScript; }
+    if (r.vendor) { s += ", vendor: " + r.vendor; }
+    s += "]";
+    lines.push(s);
+  });
+  lines.push("Task Reqs:");
+  var taskRes = rp.taskResources || {};
+  Object.keys(taskRes).sort().forEach(function (key) {
+    var r = taskRes[key];
+    lines.push("\t" + r.resourceName + ": [amount: " + r.amount + "]");
+  });
+  return lines.join("\n");
+}
+
+function initDataTable(paneId, tableId, data, columns, dtOpts) {
+  var pane = document.getElementById(paneId);
+  pane.innerHTML = '<table id="' + tableId +
+    '" class="table table-striped compact cell-border" 
style="width:100%"></table>';
+  var opts = $.extend({
+    data: data,
+    columns: columns,
+    order: [[0, "asc"]],
+    pageLength: 50,
+    deferRender: true,
+    language: { search: "Search:&#160;" }
+  }, dtOpts || {});
+  $("#" + tableId).DataTable(opts);
+}
+
+$(document).ready(function () {
+  setDataTableDefaults();
 
-$(document).ready(function(){
   // Tab state persistence
   var storedTab = localStorage.getItem("env-active-tab");
   if (storedTab) {
@@ -28,52 +105,87 @@ $(document).ready(function(){
   }
 
   document.querySelectorAll('#envTabs button[data-bs-toggle="pill"]')
-    .forEach(function(tabEl) {
-      tabEl.addEventListener("shown.bs.tab", function(event) {
+    .forEach(function (tabEl) {
+      tabEl.addEventListener("shown.bs.tab", function (event) {
         localStorage.setItem("env-active-tab", event.target.id);
+        // Adjust DataTable columns for newly visible tab
+        $(event.target.getAttribute("data-bs-target"))
+          .find(".dataTable").each(function () {
+            $(this).DataTable().columns.adjust();
+          });
       });
     });
 
-  // Column filter functionality
-  $('th').on('click', function(e) {
-    let inputBox = $(this).find('.env-table-filter-input');
-    if (inputBox.length === 0) {
-      $('<input class="env-table-filter-input form-control" type="text">')
-        .appendTo(this)
-        .focus();
-    } else {
-      inputBox.toggleClass('d-none');
-      inputBox.focus();
-    }
-    e.stopPropagation();
-  });
+  getStandAloneAppId(function (appId) {
+    var endPoint = createRESTEndPointForEnvironmentPage(appId);
+    $.getJSON(endPoint, function (response) {
+      // Runtime Information
+      var runtime = response.runtime || {};
+      var runtimeData = [
+        ["Java Version", runtime.javaVersion || ""],
+        ["Java Home", runtime.javaHome || ""],
+        ["Scala Version", runtime.scalaVersion || ""]
+      ];
+      initDataTable("runtime", "runtime-table", runtimeData, [
+        { title: "Name", width: "35%" },
+        { title: "Value", width: "65%" }
+      ], { paging: false, searching: false, info: false });
+      updateBadge("runtime-tab", runtimeData.length);
 
-  $(document).on('click', function() {
-    $('.env-table-filter-input').toggleClass('d-none', true);
-  });
+      // Spark Properties
+      var sparkProps = response.sparkProperties || [];
+      initDataTable("spark-props", "spark-props-table", sparkProps, [
+        { title: "Name", width: "35%" },
+        { title: "Value", width: "65%" }
+      ]);
+      updateBadge("spark-props-tab", sparkProps.length);
 
-  $(document).on('input', '.env-table-filter-input', function() {
-    const table = $(this).closest('table');
-    const filters = table.find('.env-table-filter-input').map(function() {
-      const columnIdx = $(this).closest('th').index();
-      const searchString = $(this).val().toLowerCase();
-      return { columnIdx, searchString };
-    }).get();
-
-    table.find('tbody tr').each(function() {
-      let showRow = true;
-      for (const filter of filters) {
-        const cellText = 
$(this).find('td').eq(filter.columnIdx).text().toLowerCase();
-        if (filter.searchString && cellText.indexOf(filter.searchString) === 
-1) {
-          showRow = false;
-          break;
+      // Resource Profiles
+      var profiles = response.resourceProfiles || [];
+      var rpData = profiles.map(function (rp) {
+        return [String(rp.id), formatResourceProfile(rp)];
+      });
+      initDataTable("resource-profiles", "resource-profiles-table", rpData, [
+        { title: "Resource Profile Id", width: "20%" },
+        {
+          title: "Resource Profile Contents",
+          width: "80%",
+          render: function (data) { return "<pre>" + data + "</pre>"; }
         }
-      }
-      if (showRow) {
-        $(this).removeClass('d-none');
-      } else {
-        $(this).addClass('d-none');
-      }
+      ], { paging: false, searching: false, info: false });
+      updateBadge("resource-profiles-tab", rpData.length);
+
+      // Hadoop Properties
+      var hadoopProps = response.hadoopProperties || [];
+      initDataTable("hadoop-props", "hadoop-props-table", hadoopProps, [
+        { title: "Name", width: "35%" },
+        { title: "Value", width: "65%" }
+      ]);
+      updateBadge("hadoop-props-tab", hadoopProps.length);
+
+      // System Properties
+      var systemProps = response.systemProperties || [];
+      initDataTable("system-props", "system-props-table", systemProps, [
+        { title: "Name", width: "35%" },
+        { title: "Value", width: "65%" }
+      ]);
+      updateBadge("system-props-tab", systemProps.length);
+
+      // Metrics Properties
+      var metricsProps = response.metricsProperties || [];
+      initDataTable("metrics-props", "metrics-props-table", metricsProps, [
+        { title: "Name", width: "35%" },
+        { title: "Value", width: "65%" }
+      ]);
+      updateBadge("metrics-props-tab", metricsProps.length);
+
+      // Classpath Entries
+      var classpathEntries = response.classpathEntries || [];
+      initDataTable("classpath", "classpath-table", classpathEntries, [
+        { title: "Resource", width: "35%" },
+        { title: "Source", width: "65%" }
+      ]);
+      updateBadge("classpath-tab", classpathEntries.length);
     });
   });
 });
diff --git a/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala 
b/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
index 423475859d59..4717579aca52 100644
--- a/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
@@ -17,82 +17,27 @@
 
 package org.apache.spark.ui.env
 
-import scala.collection.mutable.StringBuilder
 import scala.xml.Node
 
 import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.SparkConf
-import org.apache.spark.resource.{ExecutorResourceRequest, TaskResourceRequest}
 import org.apache.spark.status.AppStatusStore
 import org.apache.spark.ui._
-import org.apache.spark.util.Utils
 
 private[ui] class EnvironmentPage(
     parent: EnvironmentTab,
     conf: SparkConf,
     store: AppStatusStore) extends WebUIPage("") {
 
-  def render(request: HttpServletRequest): Seq[Node] = {
-    val appEnv = store.environmentInfo()
-    val jvmInformation = Map(
-      "Java Version" -> appEnv.runtime.javaVersion,
-      "Java Home" -> appEnv.runtime.javaHome,
-      "Scala Version" -> appEnv.runtime.scalaVersion)
-
-    def constructExecutorRequestString(execReqs: Map[String, 
ExecutorResourceRequest]): String = {
-      execReqs.map {
-        case (_, execReq) =>
-          val execStr = new StringBuilder(s"\t${execReq.resourceName}: 
[amount: ${execReq.amount}")
-          if (execReq.discoveryScript.nonEmpty) {
-            execStr ++= s", discovery: ${execReq.discoveryScript}"
-          }
-          if (execReq.vendor.nonEmpty) {
-            execStr ++= s", vendor: ${execReq.vendor}"
-          }
-          execStr ++= "]"
-          execStr.toString()
-      }.mkString("\n")
-    }
-
-    def constructTaskRequestString(taskReqs: Map[String, 
TaskResourceRequest]): String = {
-      taskReqs.map {
-        case (_, taskReq) => s"\t${taskReq.resourceName}: [amount: 
${taskReq.amount}]"
-      }.mkString("\n")
-    }
-
-    val resourceProfileInfo = store.resourceProfileInfo().map { rinfo =>
-      val einfo = constructExecutorRequestString(rinfo.executorResources)
-      val tinfo = constructTaskRequestString(rinfo.taskResources)
-      val res = s"Executor Reqs:\n$einfo\nTask Reqs:\n$tinfo"
-      (rinfo.id.toString, res)
-    }.toMap
+  private val spinner =
+    <div class="text-center p-3">
+      <div class="spinner-border text-primary" role="status">
+        <span class="visually-hidden">Loading...</span>
+      </div>
+    </div>
 
-    val resourceProfileInformationTable = 
UIUtils.listingTable(resourceProfileHeader,
-      jvmRowDataPre, resourceProfileInfo.toSeq.sortWith(_._1.toInt < 
_._1.toInt),
-      fixedWidth = true, headerClasses = headerClassesNoSortValues)
-    val runtimeInformationTable = UIUtils.listingTable(
-      propertyHeader, jvmRow, jvmInformation.toSeq.sorted, fixedWidth = true,
-      headerClasses = headerClasses)
-    val sparkProperties = Utils.redact(conf, appEnv.sparkProperties.sorted)
-    val sparkPropertiesTable = UIUtils.listingTable(propertyHeader, 
propertyRow,
-      sparkProperties, fixedWidth = true, headerClasses = headerClasses)
-    val emptyProperties = collection.Seq.empty[(String, String)]
-    val hadoopProperties =
-      Utils.redact(conf, 
Option(appEnv.hadoopProperties).getOrElse(emptyProperties).sorted)
-    val hadoopPropertiesTable = UIUtils.listingTable(propertyHeader, 
propertyRow,
-      hadoopProperties, fixedWidth = true, headerClasses = headerClasses)
-    val systemProperties = Utils.redact(conf, appEnv.systemProperties.sorted)
-    val systemPropertiesTable = UIUtils.listingTable(propertyHeader, 
propertyRow,
-      systemProperties, fixedWidth = true, headerClasses = headerClasses)
-    val metricsProperties =
-      Utils.redact(conf, 
Option(appEnv.metricsProperties).getOrElse(emptyProperties).sorted)
-    val metricsPropertiesTable = UIUtils.listingTable(propertyHeader, 
propertyRow,
-      metricsProperties, fixedWidth = true, headerClasses = headerClasses)
-    val classpathEntries = appEnv.classpathEntries.sorted
-    val classpathEntriesTable = UIUtils.listingTable(
-      classPathHeader, classPathRow, classpathEntries, fixedWidth = true,
-      headerClasses = headerClasses)
+  def render(request: HttpServletRequest): Seq[Node] = {
     val content =
       <span>
         <div class="d-flex align-items-start">
@@ -102,93 +47,77 @@ private[ui] class EnvironmentPage(
                     data-bs-target="#runtime" type="button" role="tab"
                     aria-controls="runtime" aria-selected="true">
               Runtime Information
-              <span class="badge bg-secondary 
ms-1">{jvmInformation.size}</span>
             </button>
             <button class="nav-link text-start" id="spark-props-tab" 
data-bs-toggle="pill"
                     data-bs-target="#spark-props" type="button" role="tab"
                     aria-controls="spark-props" aria-selected="false">
               Spark Properties
-              <span class="badge bg-secondary 
ms-1">{sparkProperties.size}</span>
             </button>
             <button class="nav-link text-start" id="resource-profiles-tab" 
data-bs-toggle="pill"
                     data-bs-target="#resource-profiles" type="button" 
role="tab"
                     aria-controls="resource-profiles" aria-selected="false">
               Resource Profiles
-              <span class="badge bg-secondary 
ms-1">{resourceProfileInfo.size}</span>
             </button>
             <button class="nav-link text-start" id="hadoop-props-tab" 
data-bs-toggle="pill"
                     data-bs-target="#hadoop-props" type="button" role="tab"
                     aria-controls="hadoop-props" aria-selected="false">
               Hadoop Properties
-              <span class="badge bg-secondary 
ms-1">{hadoopProperties.size}</span>
             </button>
             <button class="nav-link text-start" id="system-props-tab" 
data-bs-toggle="pill"
                     data-bs-target="#system-props" type="button" role="tab"
                     aria-controls="system-props" aria-selected="false">
               System Properties
-              <span class="badge bg-secondary 
ms-1">{systemProperties.size}</span>
             </button>
             <button class="nav-link text-start" id="metrics-props-tab" 
data-bs-toggle="pill"
                     data-bs-target="#metrics-props" type="button" role="tab"
                     aria-controls="metrics-props" aria-selected="false">
               Metrics Properties
-              <span class="badge bg-secondary 
ms-1">{metricsProperties.size}</span>
             </button>
             <button class="nav-link text-start" id="classpath-tab" 
data-bs-toggle="pill"
                     data-bs-target="#classpath" type="button" role="tab"
                     aria-controls="classpath" aria-selected="false">
               Classpath Entries
-              <span class="badge bg-secondary 
ms-1">{classpathEntries.size}</span>
             </button>
           </div>
           <div class="tab-content flex-fill" id="envTabContent">
             <div class="tab-pane fade show active" id="runtime" role="tabpanel"
                  aria-labelledby="runtime-tab">
-              {runtimeInformationTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="spark-props" role="tabpanel"
                  aria-labelledby="spark-props-tab">
-              {sparkPropertiesTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="resource-profiles" role="tabpanel"
                  aria-labelledby="resource-profiles-tab">
-              {resourceProfileInformationTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="hadoop-props" role="tabpanel"
                  aria-labelledby="hadoop-props-tab">
-              {hadoopPropertiesTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="system-props" role="tabpanel"
                  aria-labelledby="system-props-tab">
-              {systemPropertiesTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="metrics-props" role="tabpanel"
                  aria-labelledby="metrics-props-tab">
-              {metricsPropertiesTable}
+              {spinner}
             </div>
             <div class="tab-pane fade" id="classpath" role="tabpanel"
                  aria-labelledby="classpath-tab">
-              {classpathEntriesTable}
+              {spinner}
             </div>
           </div>
         </div>
-        <script src={UIUtils.prependBaseUri(request, 
"/static/environmentpage.js")}></script>
+        <script type="module"
+                src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script>
+        <script type="module"
+                src={UIUtils.prependBaseUri(request, 
"/static/environmentpage.js")}></script>
       </span>
 
-    UIUtils.headerSparkPage(request, "Environment", content, parent)
+    UIUtils.headerSparkPage(request, "Environment", content, parent, 
useDataTables = true)
   }
-
-  private def resourceProfileHeader = Seq("Resource Profile Id", "Resource 
Profile Contents")
-  private def propertyHeader = Seq("Name", "Value")
-  private def classPathHeader = Seq("Resource", "Source")
-  private def headerClasses = Seq("sorttable_alpha", "sorttable_alpha")
-  private def headerClassesNoSortValues = Seq("sorttable_numeric", 
"sorttable_nosort")
-
-  private def jvmRowDataPre(kv: (String, String)) =
-    <tr><td>{kv._1}</td><td><pre>{kv._2}</pre></td></tr>
-  private def jvmRow(kv: (String, String)) = 
<tr><td>{kv._1}</td><td>{kv._2}</td></tr>
-  private def propertyRow(kv: (String, String)) = 
<tr><td>{kv._1}</td><td>{kv._2}</td></tr>
-  private def classPathRow(data: (String, String)) = 
<tr><td>{data._1}</td><td>{data._2}</td></tr>
 }
 
 private[ui] class EnvironmentTab(


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

Reply via email to