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

zaynt4606 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new 5c116df97b [CELEBORN-2415] Add Celeborn Spark UI extension
5c116df97b is described below

commit 5c116df97be78538f8c98140f8b833ca72efda3f
Author: yew1eb <[email protected]>
AuthorDate: Fri Oct 9 22:06:17 2026 +0800

    [CELEBORN-2415] Add Celeborn Spark UI extension
    
    ### What changes were proposed in this pull request?
    
    Add a new module `celeborn-client-spark-3-ui` — a Spark UI extension that 
renders a **"Celeborn" tab** in both the live Spark UI and the History Server 
(enabled via `spark.plugins=org.apache.spark.shuffle.celeborn.CelebornPlugin`, 
supports Spark 3.x and 4.x).
    
    This is an **initial version** that lays the foundation; it intentionally 
starts minimal. The page shows:
    
    1. **Summary** — shuffle write/read bytes, time, speed, and shuffle-vs-task 
duration ratio, aggregated from Spark's built-in `TaskMetrics`
    2. **Celeborn Properties** — a collapsible table of `spark.celeborn.*` 
configs
    
    The skeleton is: `CelebornPlugin` (live UI) / `CelebornHistoryServerPlugin` 
(SHS, via `AppHistoryServerPlugin` SPI) → listener aggregates into Spark 
`KVStore` → `CelebornStatusStore` → `CelebornUITab` renders; a 
`javax`/`jakarta` servlet bridge covers Spark 3.x and 4.x. This 
plugin/store/render pipeline is designed so that fine-grained Celeborn client 
metrics can be added as collapsible sections in follow-up PRs:
    
    - **Shuffle Write Times** — serialize / copy / queue wait / compress / 
queue stall / inflight wait / drain wait / max push RTT / slow push
    - **Shuffle Read Times** — chunk wait / decompress / retry wait / max chunk 
RTT / slow chunk
    - **Shuffle Write Servers** — per-worker push bytes, push count, RTT, 
soft/hard splits, congested counts, last failure reason
    - **Shuffle Read Servers** — per-worker chunk count, read bytes, total/max 
RTT
    - **Shuffle Assignments** — partition-to-worker slot assignments
    
    ### Why are the changes needed?
    
    Spark UI already shows stage/task-level shuffle metrics, but nothing 
`Celeborn-specific`: which workers served the data, push/chunk latencies, 
congestion and split events are invisible there, while Celeborn's own metrics 
sit in cluster-level Grafana dashboards that cannot be attributed back to a 
specific application. This tab closes that gap by putting per-application 
Celeborn shuffle information inside the Spark UI and History Server, alongside 
the job's stages and tasks.
    
    ### Does this PR introduce any user-facing change?
    
    Yes.
    Setting `spark.plugins=org.apache.spark.shuffle.celeborn.CelebornPlugin` 
(with the extension jar on the classpath) adds a "Celeborn" tab to the Spark UI 
and History Server. Disabled by default; no effect otherwise.
    
    ### Does this PR resolve a correctness bug?
    
    No.
    
    ### How was this patch tested?
    
    Manual end-to-end verification:
    
    <img width="2584" height="1352" alt="image" 
src="https://github.com/user-attachments/assets/202eb4b7-389c-4dab-a2e4-799971594b92";
 />
    
    Closes #3793 from yew1eb/celeborn-ui-mvp.
    
    Authored-by: yew1eb <[email protected]>
    Signed-off-by: zhengtao <[email protected]>
    
    AI-Contributed/Feature: 0/716
    AI-Contributed/UT: 0/170
---
 client-spark/spark-3-shaded/pom.xml                |   5 +
 client-spark/spark-3-ui/pom.xml                    |  91 +++++++++++
 .../org.apache.spark.status.AppHistoryServerPlugin |  16 ++
 .../shuffle/celeborn/ui/SparkServletBridge.scala   |  24 +++
 .../shuffle/celeborn/ui/SparkServletBridge.scala   |  24 +++
 .../celeborn/CelebornHistoryServerPlugin.scala     |  50 ++++++
 .../spark/shuffle/celeborn/CelebornListener.scala  | 145 ++++++++++++++++++
 .../spark/shuffle/celeborn/CelebornPlugin.scala    |  65 ++++++++
 .../shuffle/celeborn/CelebornStatusStore.scala     |  86 +++++++++++
 .../shuffle/celeborn/ui/CelebornShufflePage.scala  | 130 ++++++++++++++++
 .../spark/shuffle/celeborn/ui/CelebornUITab.scala  |  30 ++++
 .../shuffle/celeborn/CelebornListenerSuite.scala   | 170 +++++++++++++++++++++
 client-spark/spark-4-shaded/pom.xml                |   5 +
 pom.xml                                            |  18 +++
 project/CelebornBuild.scala                        |  27 +++-
 15 files changed, 884 insertions(+), 2 deletions(-)

diff --git a/client-spark/spark-3-shaded/pom.xml 
b/client-spark/spark-3-shaded/pom.xml
index bc8c2065e2..8ad454779d 100644
--- a/client-spark/spark-3-shaded/pom.xml
+++ b/client-spark/spark-3-shaded/pom.xml
@@ -34,6 +34,11 @@
       <artifactId>celeborn-client-spark-3_${scala.binary.version}</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      
<artifactId>celeborn-client-spark-3-ui_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/client-spark/spark-3-ui/pom.xml b/client-spark/spark-3-ui/pom.xml
new file mode 100644
index 0000000000..d11f0a29a6
--- /dev/null
+++ b/client-spark/spark-3-ui/pom.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~    http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.celeborn</groupId>
+    <artifactId>celeborn-parent_${scala.binary.version}</artifactId>
+    <version>${project.version}</version>
+    <relativePath>../../pom.xml</relativePath>
+  </parent>
+
+  <artifactId>celeborn-client-spark-3-ui_${scala.binary.version}</artifactId>
+  <packaging>jar</packaging>
+  <name>Celeborn Spark UI Plugin</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      <artifactId>celeborn-client-spark-3_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.spark</groupId>
+      <artifactId>spark-core_${scala.binary.version}</artifactId>
+      <scope>provided</scope>
+      <exclusions>
+        <exclusion>
+          <groupId>org.xerial.snappy</groupId>
+          <artifactId>snappy-java</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>javax.servlet</groupId>
+      <artifactId>javax.servlet-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.servlet</groupId>
+      <artifactId>jakarta.servlet-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>net.alchim31.maven</groupId>
+        <artifactId>scala-maven-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>com.diffplug.spotless</groupId>
+        <artifactId>spotless-maven-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>org.codehaus.mojo</groupId>
+        <artifactId>build-helper-maven-plugin</artifactId>
+        <version>3.6.0</version>
+        <executions>
+          <execution>
+            <id>add-servlet-source</id>
+            <goals>
+              <goal>add-source</goal>
+            </goals>
+            <phase>generate-sources</phase>
+            <configuration>
+              <sources>
+                <source>src/main/${servlet.source.dir}</source>
+              </sources>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git 
a/client-spark/spark-3-ui/src/main/resources/META-INF/services/org.apache.spark.status.AppHistoryServerPlugin
 
b/client-spark/spark-3-ui/src/main/resources/META-INF/services/org.apache.spark.status.AppHistoryServerPlugin
new file mode 100644
index 0000000000..9710c39a11
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/resources/META-INF/services/org.apache.spark.status.AppHistoryServerPlugin
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.spark.shuffle.celeborn.CelebornHistoryServerPlugin
diff --git 
a/client-spark/spark-3-ui/src/main/scala-spark3/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
 
b/client-spark/spark-3-ui/src/main/scala-spark3/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
new file mode 100644
index 0000000000..8b397d566c
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala-spark3/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn.ui
+
+// Spark 3.x uses javax.servlet. This type alias bridges the API difference
+// so a single CelebornShufflePage.scala compiles for both Spark 3 and Spark 4.
+object SparkServletBridge {
+  type HttpServletRequest = javax.servlet.http.HttpServletRequest
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala-spark4/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
 
b/client-spark/spark-3-ui/src/main/scala-spark4/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
new file mode 100644
index 0000000000..8b52a5e1ed
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala-spark4/org/apache/spark/shuffle/celeborn/ui/SparkServletBridge.scala
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn.ui
+
+// Spark 4.x uses jakarta.servlet. This type alias bridges the API difference
+// so a single CelebornShufflePage.scala compiles for both Spark 3 and Spark 4.
+object SparkServletBridge {
+  type HttpServletRequest = jakarta.servlet.http.HttpServletRequest
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornHistoryServerPlugin.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornHistoryServerPlugin.scala
new file mode 100644
index 0000000000..3634f2701e
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornHistoryServerPlugin.scala
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn
+
+import org.apache.spark.SparkConf
+import org.apache.spark.scheduler.SparkListener
+import org.apache.spark.shuffle.celeborn.ui.CelebornUITab
+import org.apache.spark.status.{AppHistoryServerPlugin, ElementTrackingStore}
+import org.apache.spark.ui.SparkUI
+
+/** Registered via SPI at 
META-INF/services/org.apache.spark.status.AppHistoryServerPlugin. */
+class CelebornHistoryServerPlugin extends AppHistoryServerPlugin {
+
+  override def createListeners(
+      conf: SparkConf,
+      store: ElementTrackingStore): Seq[SparkListener] = {
+    val listener = new CelebornListener(store, conf, requirePluginOptIn = true)
+    // Persist the final accumulated values when replay finishes, covering logs
+    // whose last events fall inside the throttle interval and that may lack an
+    // ApplicationEnd event.
+    store.onFlush(listener.flush())
+    Seq(listener)
+  }
+
+  override def setupUI(ui: SparkUI): Unit = {
+    val statusStore = new CelebornStatusStore(ui.store.store)
+    // Only attach the tab for applications that opted in via spark.plugins or
+    // spark.plugins.defaultList.
+    if (statusStore.extensionEnabled()) {
+      new CelebornUITab(statusStore, ui)
+    }
+  }
+
+  override def displayOrder: Int = 1
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
new file mode 100644
index 0000000000..1348ee2f8f
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn
+
+import java.util.concurrent.atomic.AtomicLong
+
+import org.apache.spark.SparkConf
+import org.apache.spark.internal.Logging
+import org.apache.spark.scheduler._
+import org.apache.spark.util.Utils
+import org.apache.spark.util.kvstore.KVStore
+
+/**
+ * Collects Celeborn shuffle metrics into the Spark KVStore for live UI and
+ * HistoryServer replay.
+ *
+ * When `requirePluginOptIn` is true (History Server replay), collection stays
+ * disabled until the application's recorded `spark.plugins` or
+ * `spark.plugins.defaultList` contains [[CelebornPlugin]], and an enable 
marker
+ * is persisted so `setupUI` can decide whether to attach the Celeborn tab.
+ */
+private[celeborn] class CelebornListener(
+    val kvstore: KVStore,
+    val conf: SparkConf,
+    requirePluginOptIn: Boolean = false)
+  extends SparkListener with Logging {
+
+  @volatile private var pluginEnabled = !requirePluginOptIn
+
+  private val totalWriteBytes = new AtomicLong(0L)
+  private val totalWriteTimeMs = new AtomicLong(0L)
+  private val totalReadBytes = new AtomicLong(0L)
+  private val totalFetchWaitTimeMs = new AtomicLong(0L)
+  private val totalTaskDurationMs = new AtomicLong(0L)
+
+  private val lastUpdateTimestamp = new AtomicLong(-1L)
+  private val updateIntervalMillis = 5000L
+
+  def register(sc: org.apache.spark.SparkContext): Unit = {
+    sc.addSparkListener(this)
+    logInfo("CelebornListener registered successfully")
+  }
+
+  override def onTaskEnd(taskEnd: SparkListenerTaskEnd): Unit = {
+    if (!pluginEnabled) {
+      return
+    }
+    Option(taskEnd.taskMetrics).foreach { metrics =>
+      totalWriteBytes.addAndGet(metrics.shuffleWriteMetrics.bytesWritten)
+      // writeTime is in nanoseconds; normalize to ms.
+      totalWriteTimeMs.addAndGet(metrics.shuffleWriteMetrics.writeTime / 
1000000L)
+      totalReadBytes.addAndGet(metrics.shuffleReadMetrics.totalBytesRead)
+      totalFetchWaitTimeMs.addAndGet(metrics.shuffleReadMetrics.fetchWaitTime)
+      totalTaskDurationMs.addAndGet(taskEnd.taskInfo.duration)
+    }
+    mayUpdate()
+  }
+
+  override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = {
+    // Flush per-job so that tasks finishing within the throttle interval of 
the
+    // previous flush are not lost when the application stays alive but idle.
+    mayUpdate(force = true)
+  }
+
+  override def onEnvironmentUpdate(environmentUpdate: 
SparkListenerEnvironmentUpdate): Unit = {
+    val sparkProps = environmentUpdate.environmentDetails
+      .getOrElse("Spark Properties", Seq.empty)
+    if (!pluginEnabled) {
+      val pluginClass = classOf[CelebornPlugin].getName
+      // Spark loads plugins from both keys: `spark.plugins.defaultList` 
allows a
+      // default plugin list in the config file that `spark.plugins` does not 
overwrite.
+      val optedIn = sparkProps.exists { case (k, v) =>
+        (k == "spark.plugins" || k == "spark.plugins.defaultList") &&
+          v.split(",").exists(_.trim == pluginClass)
+      }
+      if (optedIn) {
+        pluginEnabled = true
+        kvstore.write(new CelebornExtensionEnabledUIData())
+      } else {
+        return
+      }
+    }
+    val celebornProps = Utils.redact(
+      conf,
+      sparkProps.filter { case (k, _) => k.startsWith("spark.celeborn.") })
+      .sortBy(_._1)
+    if (celebornProps.nonEmpty) {
+      kvstore.write(new CelebornPropertiesUIData(celebornProps.toList))
+    }
+  }
+
+  override def onApplicationEnd(applicationEnd: SparkListenerApplicationEnd): 
Unit = {
+    mayUpdate(force = true)
+    logInfo("CelebornListener: application ended, final flush completed")
+  }
+
+  /** Flushes the current aggregations immediately, bypassing the throttle. */
+  def flush(): Unit = {
+    lastUpdateTimestamp.set(System.currentTimeMillis())
+    flushAggregations()
+  }
+
+  private def mayUpdate(force: Boolean = false): Unit = {
+    val now = System.currentTimeMillis()
+    val last = lastUpdateTimestamp.get()
+    if (!force && (last != -1L && (now - last) < updateIntervalMillis)) {
+      return
+    }
+    if (lastUpdateTimestamp.compareAndSet(last, now) || force) {
+      flushAggregations()
+    }
+  }
+
+  private def flushAggregations(): Unit = {
+    if (!pluginEnabled) {
+      return
+    }
+    try {
+      kvstore.write(AggregatedTaskInfoUIData(
+        totalWriteBytes.get(),
+        totalWriteTimeMs.get(),
+        totalReadBytes.get(),
+        totalFetchWaitTimeMs.get(),
+        totalTaskDurationMs.get()))
+    } catch {
+      case e: Exception =>
+        logWarning("Failed to flush CelebornListener aggregations", e)
+    }
+  }
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
new file mode 100644
index 0000000000..d0e0caae50
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn
+
+import java.util
+
+import org.apache.spark.SparkContext
+import org.apache.spark.api.plugin.{DriverPlugin, PluginContext, SparkPlugin}
+import org.apache.spark.internal.Logging
+import org.apache.spark.shuffle.celeborn.ui.CelebornUITab
+
+/**
+ * SparkPlugin entry point for the Celeborn UI extension.
+ *
+ * Configure via:
+ * {{{
+ *   spark.plugins=org.apache.spark.shuffle.celeborn.CelebornPlugin
+ * }}}
+ */
+class CelebornPlugin extends SparkPlugin {
+
+  override def driverPlugin(): DriverPlugin = new CelebornDriverPlugin()
+
+  override def executorPlugin(): org.apache.spark.api.plugin.ExecutorPlugin = 
null
+}
+
+private class CelebornDriverPlugin extends DriverPlugin with Logging {
+
+  private var sc: SparkContext = _
+
+  override def init(
+      sc: SparkContext,
+      ctx: PluginContext): util.Map[String, String] = {
+    logInfo("Initializing CelebornDriverPlugin...")
+    this.sc = sc
+    val kvStore = sc.statusStore.store
+    new CelebornListener(kvStore, sc.conf).register(sc)
+    java.util.Collections.emptyMap[String, String]()
+  }
+
+  override def registerMetrics(
+      appId: String,
+      ctx: PluginContext): Unit = {
+    sc.ui.foreach { ui =>
+      new CelebornUITab(new CelebornStatusStore(ui.store.store), ui)
+    }
+  }
+
+  override def shutdown(): Unit = {}
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
new file mode 100644
index 0000000000..9a8ffe5cf1
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn
+
+import com.fasterxml.jackson.annotation.JsonIgnore
+import org.apache.spark.util.kvstore.{KVIndex, KVStore}
+
+private[celeborn] case class AggregatedTaskInfoUIData(
+    shuffleWriteBytes: Long,
+    shuffleWriteTimeMs: Long,
+    shuffleReadBytes: Long,
+    shuffleFetchWaitTimeMs: Long,
+    taskDurationMs: Long) {
+
+  @JsonIgnore
+  @KVIndex
+  def id: String = classOf[AggregatedTaskInfoUIData].getName
+}
+
+private[celeborn] class CelebornPropertiesUIData(
+    val info: Seq[(String, String)]) {
+
+  @JsonIgnore
+  @KVIndex
+  def id: String = classOf[CelebornPropertiesUIData].getName
+}
+
+/**
+ * Marker persisted during History Server replay when the application's 
recorded
+ * `spark.plugins` or `spark.plugins.defaultList` contains [[CelebornPlugin]].
+ * `setupUI` runs on a fresh plugin
+ * instance (FsHistoryProvider loads plugins again after replay), so the opt-in
+ * state has to be passed through the KVStore rather than in memory.
+ */
+private[celeborn] class CelebornExtensionEnabledUIData {
+
+  @JsonIgnore
+  @KVIndex
+  def id: String = classOf[CelebornExtensionEnabledUIData].getName
+}
+
+private[celeborn] class CelebornStatusStore(store: KVStore) {
+
+  def aggregatedTaskInfo(): AggregatedTaskInfoUIData = {
+    val kClass = classOf[AggregatedTaskInfoUIData]
+    try {
+      store.read(kClass, kClass.getName)
+    } catch {
+      case _: NoSuchElementException => AggregatedTaskInfoUIData(0L, 0L, 0L, 
0L, 0L)
+    }
+  }
+
+  def celebornProperties(): CelebornPropertiesUIData = {
+    val kClass = classOf[CelebornPropertiesUIData]
+    try {
+      store.read(kClass, kClass.getName)
+    } catch {
+      case _: NoSuchElementException => new CelebornPropertiesUIData(Seq.empty)
+    }
+  }
+
+  def extensionEnabled(): Boolean = {
+    val kClass = classOf[CelebornExtensionEnabledUIData]
+    try {
+      store.read(kClass, kClass.getName)
+      true
+    } catch {
+      case _: NoSuchElementException => false
+    }
+  }
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
new file mode 100644
index 0000000000..4a57652fcf
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn.ui
+
+import scala.xml.Node
+
+import org.apache.spark.internal.Logging
+import 
org.apache.spark.shuffle.celeborn.ui.SparkServletBridge.HttpServletRequest
+import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.util.Utils
+
+private[celeborn] class CelebornShufflePage(parent: CelebornUITab)
+  extends WebUIPage("") with Logging {
+
+  private val store = parent.store
+
+  override def render(request: HttpServletRequest): Seq[Node] = {
+    try {
+      renderBody(request)
+    } catch {
+      case e: Throwable =>
+        logError("Failed to render Celeborn Shuffle page", e)
+        val errorContent =
+          <div class="row-fluid">
+            <div class="span12">
+              <h4>
+                <strong>Celeborn Shuffle</strong>
+              </h4>
+              <div class="alert alert-error">
+                <pre>Failed to render the Celeborn page: {e.getMessage}</pre>
+              </div>
+            </div>
+          </div>
+        UIUtils.headerSparkPage(request, "Celeborn Shuffle", errorContent, 
parent)
+    }
+  }
+
+  private def renderBody(request: HttpServletRequest): Seq[Node] = {
+    val taskInfo = store.aggregatedTaskInfo()
+    val properties = store.celebornProperties()
+
+    val writeBytes = taskInfo.shuffleWriteBytes
+    val readBytes = taskInfo.shuffleReadBytes
+    val writeMs = taskInfo.shuffleWriteTimeMs
+    val readMs = taskInfo.shuffleFetchWaitTimeMs
+    val durationMs = taskInfo.taskDurationMs
+
+    def mbps(bytes: Long, ms: Long): String =
+      if (ms <= 0) "N/A" else f"${bytes.toDouble / 1000.0 / 1000.0 / 
(ms.toDouble / 1000.0)}%.2f"
+    def pct(part: Long, total: Long): String =
+      if (total <= 0) "N/A" else f"${part.toDouble * 100.0 / 
total.toDouble}%.1f%%"
+
+    val summary =
+      <div>
+        <ul class="list-unstyled">
+          <li>
+            <strong>Shuffle Write: </strong>
+            {
+        s"${Utils.bytesToString(writeBytes)} | Time: ${UIUtils.formatDuration(
+          writeMs)} | Speed: ${mbps(writeBytes, writeMs)} MB/s"
+      }
+          </li>
+          <li>
+            <strong>Shuffle Read: </strong>
+            {
+        s"${Utils.bytesToString(readBytes)} | Time: ${UIUtils.formatDuration(
+          readMs)} | Speed: ${mbps(readBytes, readMs)} MB/s"
+      }
+          </li>
+          <li>
+            <strong>Shuffle Duration (write+read) / Task Duration: </strong>
+            {
+        s"${pct(writeMs + readMs, durationMs)} (Write ${pct(
+          writeMs,
+          durationMs)}, Read ${pct(readMs, durationMs)})"
+      }
+          </li>
+        </ul>
+      </div>
+
+    val propertiesTable = UIUtils.listingTable(
+      propertyHeader,
+      propertyRow,
+      properties.info,
+      fixedWidth = true,
+      headerClasses = headerClasses)
+
+    val content =
+      <span>
+        {summary}
+        <span class="collapse-aggregated-celebornProperties collapse-table"
+            onClick="collapseTable('collapse-aggregated-celebornProperties',
+            'aggregated-celebornProperties')">
+          <h4>
+            <span class="collapse-table-arrow arrow-open"></span>
+            <a>Celeborn Properties</a>
+          </h4>
+        </span>
+        <div class="aggregated-celebornProperties collapsible-table">
+          {propertiesTable}
+        </div>
+      </span>
+
+    UIUtils.headerSparkPage(request, "Celeborn Shuffle", content, parent)
+  }
+
+  private def propertyHeader: Seq[String] = Seq("Name", "Value")
+  private def headerClasses: Seq[String] = Seq("sorttable_alpha", 
"sorttable_alpha")
+
+  private def propertyRow(kv: (String, String)): Seq[Node] =
+    <tr>
+      <td>{kv._1}</td>
+      <td>{kv._2}</td>
+    </tr>
+}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornUITab.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornUITab.scala
new file mode 100644
index 0000000000..d9a135181d
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornUITab.scala
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn.ui
+
+import org.apache.spark.shuffle.celeborn.CelebornStatusStore
+import org.apache.spark.ui.{SparkUI, SparkUITab}
+
+private[celeborn] class CelebornUITab(val store: CelebornStatusStore, sparkUI: 
SparkUI)
+  extends SparkUITab(sparkUI, "celeborn") {
+
+  override val name: String = "Celeborn"
+
+  attachPage(new CelebornShufflePage(this))
+  sparkUI.attachTab(this)
+}
diff --git 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
new file mode 100644
index 0000000000..ed9ff78ad4
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.shuffle.celeborn
+
+import org.apache.spark.{SparkConf, Success, TaskState}
+import org.apache.spark.executor.TaskMetrics
+import org.apache.spark.internal.config.Status.ASYNC_TRACKING_ENABLED
+import org.apache.spark.scheduler.{JobSucceeded, 
SparkListenerEnvironmentUpdate, SparkListenerJobEnd, SparkListenerTaskEnd, 
TaskInfo, TaskLocality}
+import org.apache.spark.status.ElementTrackingStore
+import org.apache.spark.util.Utils
+import org.apache.spark.util.kvstore.InMemoryStore
+import org.junit.Assert.{assertEquals, assertFalse, assertTrue}
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(classOf[JUnit4])
+class CelebornListenerSuite {
+
+  private val pluginClass = classOf[CelebornPlugin].getName
+
+  private def newTaskEnd(
+      writeBytes: Long,
+      writeTimeMs: Long,
+      fetchWaitMs: Long,
+      durationMs: Long): SparkListenerTaskEnd = {
+    val metrics = TaskMetrics.empty
+    metrics.shuffleWriteMetrics.incBytesWritten(writeBytes)
+    metrics.shuffleWriteMetrics.incWriteTime(writeTimeMs * 1000000L)
+    metrics.shuffleReadMetrics.incFetchWaitTime(fetchWaitMs)
+    val launchTime = 1000L
+    val info = new TaskInfo(
+      0L,
+      0,
+      0,
+      launchTime,
+      "exec-0",
+      "localhost",
+      TaskLocality.PROCESS_LOCAL,
+      false)
+    info.markFinished(TaskState.FINISHED, launchTime + durationMs)
+    SparkListenerTaskEnd(0, 0, "ShuffleMapTask", Success, info, null, metrics)
+  }
+
+  private def envUpdate(sparkProps: (String, String)*): 
SparkListenerEnvironmentUpdate = {
+    SparkListenerEnvironmentUpdate(Map("Spark Properties" -> sparkProps.toSeq))
+  }
+
+  @Test
+  def flushOnJobEndWithinThrottleInterval(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    listener.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    listener.onTaskEnd(newTaskEnd(200L, 20L, 2L, 6L))
+    // The second task lands inside the throttle window: only the first is 
persisted so far.
+    assertEquals(100L, statusStore.aggregatedTaskInfo().shuffleWriteBytes)
+
+    listener.onJobEnd(SparkListenerJobEnd(0, 2000L, JobSucceeded))
+    assertEquals(300L, statusStore.aggregatedTaskInfo().shuffleWriteBytes)
+    assertEquals(30L, statusStore.aggregatedTaskInfo().shuffleWriteTimeMs)
+    assertEquals(3L, statusStore.aggregatedTaskInfo().shuffleFetchWaitTimeMs)
+    assertEquals(11L, statusStore.aggregatedTaskInfo().taskDurationMs)
+  }
+
+  @Test
+  def flushTriggerPersistsFinalValuesOnReplayClose(): Unit = {
+    val conf = new SparkConf().set(ASYNC_TRACKING_ENABLED, false)
+    val store = new InMemoryStore()
+    val tracking = new ElementTrackingStore(store, conf)
+    val statusStore = new CelebornStatusStore(tracking)
+    val listener = new CelebornListener(tracking, conf, requirePluginOptIn = 
true)
+    tracking.onFlush(listener.flush())
+
+    listener.onEnvironmentUpdate(envUpdate("spark.plugins" -> pluginClass))
+    listener.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    listener.onTaskEnd(newTaskEnd(200L, 20L, 2L, 6L))
+    // Replay finishes within the throttle window without an ApplicationEnd 
event.
+    tracking.close(false)
+
+    assertEquals(300L, statusStore.aggregatedTaskInfo().shuffleWriteBytes)
+    assertEquals(11L, statusStore.aggregatedTaskInfo().taskDurationMs)
+  }
+
+  @Test
+  def collectionGatedOnPluginOptIn(): Unit = {
+    // Without the plugin in the recorded spark.plugins: nothing is collected 
or persisted.
+    val store1 = new InMemoryStore()
+    val statusStore1 = new CelebornStatusStore(store1)
+    val listener1 = new CelebornListener(store1, new SparkConf(), 
requirePluginOptIn = true)
+    listener1.onEnvironmentUpdate(envUpdate(
+      "spark.celeborn.master.endpoints" -> "host:9097"))
+    listener1.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    listener1.onJobEnd(SparkListenerJobEnd(0, 2000L, JobSucceeded))
+    listener1.flush()
+    assertFalse(statusStore1.extensionEnabled())
+    assertEquals(0L, statusStore1.aggregatedTaskInfo().shuffleWriteBytes)
+    assertTrue(statusStore1.celebornProperties().info.isEmpty)
+
+    // With the plugin in the recorded spark.plugins: collection and tab 
marker are enabled.
+    val store2 = new InMemoryStore()
+    val statusStore2 = new CelebornStatusStore(store2)
+    val listener2 = new CelebornListener(store2, new SparkConf(), 
requirePluginOptIn = true)
+    listener2.onEnvironmentUpdate(envUpdate(
+      "spark.plugins" -> s"com.example.OtherPlugin,$pluginClass",
+      "spark.celeborn.master.endpoints" -> "host:9097"))
+    listener2.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    assertTrue(statusStore2.extensionEnabled())
+    assertEquals(100L, statusStore2.aggregatedTaskInfo().shuffleWriteBytes)
+    assertTrue(statusStore2.celebornProperties().info.exists(
+      _._1 == "spark.celeborn.master.endpoints"))
+  }
+
+  @Test
+  def pluginOptInViaDefaultList(): Unit = {
+    // The plugin can also be loaded via spark.plugins.defaultList (e.g. from 
the
+    // Spark default config file); replay must recognize that as opt-in too.
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf(), 
requirePluginOptIn = true)
+    listener.onEnvironmentUpdate(envUpdate(
+      "spark.plugins.defaultList" -> s"com.example.OtherPlugin, $pluginClass"))
+    listener.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    assertTrue(statusStore.extensionEnabled())
+    assertEquals(100L, statusStore.aggregatedTaskInfo().shuffleWriteBytes)
+  }
+
+  @Test
+  def sensitivePropertiesAreRedacted(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    listener.onEnvironmentUpdate(envUpdate(
+      "spark.celeborn.ssl.rpc_service.trustStorePassword" -> 
"top-secret-password",
+      "spark.celeborn.storage.oss.secret.key" -> "oss-secret-key",
+      "spark.celeborn.master.endpoints" -> "host1:9097,host2:9097",
+      "spark.executor.memory" -> "1g"))
+
+    val props = statusStore.celebornProperties().info.toMap
+
+    // Sensitive values are redacted, same as Spark's Environment tab.
+    assertEquals(
+      Utils.REDACTION_REPLACEMENT_TEXT,
+      props("spark.celeborn.ssl.rpc_service.trustStorePassword"))
+    assertEquals(Utils.REDACTION_REPLACEMENT_TEXT, 
props("spark.celeborn.storage.oss.secret.key"))
+
+    // Ordinary Celeborn property remains visible.
+    assertEquals("host1:9097,host2:9097", 
props("spark.celeborn.master.endpoints"))
+
+    // Non-Celeborn properties are filtered out.
+    assertFalse(props.contains("spark.executor.memory"))
+  }
+}
diff --git a/client-spark/spark-4-shaded/pom.xml 
b/client-spark/spark-4-shaded/pom.xml
index 5e741d6017..bf0dbcb4e0 100644
--- a/client-spark/spark-4-shaded/pom.xml
+++ b/client-spark/spark-4-shaded/pom.xml
@@ -34,6 +34,11 @@
       <artifactId>celeborn-client-spark-3_${scala.binary.version}</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.apache.celeborn</groupId>
+      
<artifactId>celeborn-client-spark-3-ui_${scala.binary.version}</artifactId>
+      <version>${project.version}</version>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/pom.xml b/pom.xml
index 0bab4860e4..878646622d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1516,6 +1516,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1527,6 +1528,7 @@
         <spark.version>3.0.3</spark.version>
         <zstd-jni.version>1.4.4-3</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1537,6 +1539,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1548,6 +1551,7 @@
         <spark.version>3.1.3</spark.version>
         <zstd-jni.version>1.4.8-1</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1558,6 +1562,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1569,6 +1574,7 @@
         <spark.version>3.2.4</spark.version>
         <zstd-jni.version>1.5.0-4</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1579,6 +1585,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1590,6 +1597,7 @@
         <spark.version>3.3.4</spark.version>
         <zstd-jni.version>1.5.2-1</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1600,6 +1608,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1611,6 +1620,7 @@
         <spark.version>3.4.4</spark.version>
         <zstd-jni.version>1.5.2-5</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1621,6 +1631,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-3.5-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-3-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1632,6 +1643,7 @@
         <spark.version>3.5.8</spark.version>
         <zstd-jni.version>1.5.5-4</zstd-jni.version>
         <commons-lang3.version>3.17.0</commons-lang3.version>
+        <servlet.source.dir>scala-spark3</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1642,6 +1654,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-4-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-4-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1652,6 +1665,7 @@
         <scala.binary.version>2.13</scala.binary.version>
         <spark.version>4.0.3</spark.version>
         <zstd-jni.version>1.5.6-9</zstd-jni.version>
+        <servlet.source.dir>scala-spark4</servlet.source.dir>
       </properties>
     </profile>
 
@@ -1662,6 +1676,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-4-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-4-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1672,6 +1687,7 @@
         <scala.binary.version>2.13</scala.binary.version>
         <spark.version>4.1.2</spark.version>
         <zstd-jni.version>1.5.7-6</zstd-jni.version>
+        <servlet.source.dir>scala-spark4</servlet.source.dir>
       </properties>
       <dependencyManagement>
         <dependencies>
@@ -1691,6 +1707,7 @@
         <module>client-spark/spark-3</module>
         <module>client-spark/spark-3-columnar-common</module>
         <module>client-spark/spark-4-columnar-shuffle</module>
+        <module>client-spark/spark-3-ui</module>
         <module>client-spark/spark-4-shaded</module>
         <module>tests/spark-it</module>
       </modules>
@@ -1701,6 +1718,7 @@
         <scala.binary.version>2.13</scala.binary.version>
         <spark.version>4.2.0</spark.version>
         <zstd-jni.version>1.5.7-7</zstd-jni.version>
+        <servlet.source.dir>scala-spark4</servlet.source.dir>
       </properties>
       <dependencyManagement>
         <dependencies>
diff --git a/project/CelebornBuild.scala b/project/CelebornBuild.scala
index 6c9abcd735..1a00e98626 100644
--- a/project/CelebornBuild.scala
+++ b/project/CelebornBuild.scala
@@ -1008,6 +1008,7 @@ object Spark40 extends SparkClientProjects {
   val zstdJniVersion = "1.5.6-9"
   val scalaBinaryVersion = "2.13"
 
+  override val servletSourceDir: String = "scala-spark4"
   override val sparkColumnarShuffleVersion: String = "4"
 }
 
@@ -1025,6 +1026,7 @@ object Spark41 extends SparkClientProjects {
   val zstdJniVersion = "1.5.7-6"
   val scalaBinaryVersion = "2.13"
 
+  override val servletSourceDir: String = "scala-spark4"
   override val sparkColumnarShuffleVersion: String = "4"
   override val paranamerVersionOverride: Option[String] = Some("2.8.3")
 }
@@ -1044,6 +1046,7 @@ object Spark42 extends SparkClientProjects {
   val scalaBinaryVersion = "2.13"
 
   override val lz4JavaGroup = "at.yawk.lz4"
+  override val servletSourceDir: String = "scala-spark4"
   override val sparkColumnarShuffleVersion: String = "4"
   override val paranamerVersionOverride: Option[String] = Some("2.8.3")
 }
@@ -1066,15 +1069,19 @@ trait SparkClientProjects {
 
   val includeColumnarShuffle: Boolean = true
 
+  // Mirrors Maven's `servlet.source.dir`: Spark 3.x uses javax.servlet,
+  // Spark 4.x uses jakarta.servlet.
+  val servletSourceDir: String = "scala-spark3"
+
   def modules: Seq[Project] = {
-    val seq = Seq(sparkCommon, sparkClient, sparkIt, sparkGroup, 
sparkClientShade)
+    val seq = Seq(sparkCommon, sparkClient, sparkClientUi, sparkIt, 
sparkGroup, sparkClientShade)
     if (includeColumnarShuffle) seq ++ Seq(sparkColumnarCommon, 
sparkColumnarShuffle) else seq
   }
 
   // for test only, don't use this group for any other projects
   lazy val sparkGroup = {
     val p = (project withId "celeborn-spark-group")
-      .aggregate(sparkCommon, sparkClient, sparkIt)
+      .aggregate(sparkCommon, sparkClient, sparkClientUi, sparkIt)
     if (includeColumnarShuffle) {
       p.aggregate(sparkColumnarCommon, sparkColumnarShuffle)
     } else {
@@ -1116,6 +1123,21 @@ trait SparkClientProjects {
       )
   }
 
+  def sparkClientUi: Project = {
+    Project("celeborn-client-spark-3-ui", file("client-spark/spark-3-ui"))
+      .dependsOn(sparkClient)
+      .settings (
+        commonSettings,
+        libraryDependencies ++= Seq(
+          "org.apache.spark" %% "spark-core" % sparkVersion % "provided",
+          Dependencies.javaxServletApi % "provided",
+          Dependencies.jakartaServletApi % "provided"
+        ) ++ commonUnitTestDependencies,
+        // Mirrors Maven's build-helper-maven-plugin `add-servlet-source` 
execution.
+        Compile / unmanagedSourceDirectories += baseDirectory.value / "src" / 
"main" / servletSourceDir
+      )
+  }
+
   def sparkColumnarCommon: Project = {
     Project("celeborn-spark-3-columnar-common", 
file("client-spark/spark-3-columnar-common"))
       // ref: 
https://www.scala-sbt.org/1.x/docs/Multi-Project.html#Classpath+dependencies
@@ -1190,6 +1212,7 @@ trait SparkClientProjects {
   def sparkClientShade: Project = {
     var p = Project(sparkClientShadedProjectName, 
file(sparkClientShadedProjectPath))
       .dependsOn(sparkClient)
+      .dependsOn(sparkClientUi)
 
     if (includeColumnarShuffle) {
       p = p.dependsOn(sparkColumnarShuffle)

Reply via email to