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 89a548711474 [SPARK-55766][UI] Support dark mode with Bootstrap 5
theme toggle
89a548711474 is described below
commit 89a548711474e98a79a75d7f6413fdc09a821187
Author: Kent Yao <[email protected]>
AuthorDate: Sat Mar 7 02:10:32 2026 +0800
[SPARK-55766][UI] Support dark mode with Bootstrap 5 theme toggle
### What changes were proposed in this pull request?
Adds dark mode support to the Spark Web UI using Bootstrap 5
`data-bs-theme` attribute and a theme toggle button.
**Changes (8 files, +112/-9):**
**Theme toggle (`webui.js`):**
- `initThemeToggle()`: detects saved preference or system
`prefers-color-scheme`, sets `data-bs-theme` on `<html>`
- `updateThemeButton()`: swaps logo (`spark-logo.svg` ↔
`spark-logo-rev.svg`) and updates tooltip
- Toggle persisted in `localStorage("spark-theme")` across page navigations
**FOUC prevention (`UIUtils.scala`):**
- Inline `<script>` in `<head>` sets theme before DOM renders — no flash of
wrong theme
- Added `data-bs-theme="light"` default on `<html>` tags in both
`headerSparkPage` and `basicSparkPage`
- Theme toggle button (◑ half-circle) in navbar
**Logo dark mode (`spark-logo-rev.svg`):**
- Added white-text version from
[spark-website](https://github.com/apache/spark-website) repo for dark
backgrounds
- JS swaps logos on theme toggle
**Dark mode CSS fixes:**
- `webui.css`: `pre` text color uses `var(--bs-body-color)` for log pages
- `timeline-view.css`: 33 lines of vis-timeline overrides (axis labels,
grid lines, panel borders, item content) for dark mode
- `spark-sql-viz.css`: side panel header uses theme variables, SVG text
color adapts, edge labels use `var(--bs-secondary-color)`
- `executorspage.js`: task duration text color `"black"` →
`"var(--bs-body-color)"`
- `spark-sql-viz.js`: removed hardcoded edge label fill color
**Prerequisites (already merged):**
- SPARK-55853: Migrated ~150 hardcoded CSS colors to BS5 CSS custom
properties with `[data-bs-theme="dark"]` variants
### Why are the changes needed?
Dark mode is a standard modern UI feature that reduces eye strain and
improves readability in low-light environments. Bootstrap 5.3+ has built-in
dark mode support via `data-bs-theme="dark"` — with the CSS variable migration
(SPARK-55853) already done, adding the toggle is straightforward.
### Does this PR introduce _any_ user-facing change?
Yes — a ◑ theme toggle button appears in the navbar. Clicking it switches
between light and dark mode. The preference is saved in localStorage and
respects `prefers-color-scheme` as the initial default.
### How was this patch tested?
- `lint-js` passes
- `scalastyle` passes
- `UIUtilsSuite` (8 tests) passes
- Manual verification across all pages (Jobs, Stages, SQL, Executors,
Environment, Storage)
### Was this patch authored or co-authored using generative AI tooling?
Yes, co-authored with GitHub Copilot.
Closes #54648 from yaooqinn/SPARK-55766.
Authored-by: Kent Yao <[email protected]>
Signed-off-by: Kent Yao <[email protected]>
---
.../org/apache/spark/ui/static/executorspage.js | 2 +-
.../org/apache/spark/ui/static/spark-logo-rev.svg | 7 ++++
.../org/apache/spark/ui/static/timeline-view.css | 37 ++++++++++++++++++++
.../resources/org/apache/spark/ui/static/webui.css | 1 +
.../resources/org/apache/spark/ui/static/webui.js | 39 ++++++++++++++++++++++
.../main/scala/org/apache/spark/ui/UIUtils.scala | 21 +++++++++---
dev/.rat-excludes | 1 +
.../sql/execution/ui/static/spark-sql-viz.css | 12 +++++--
.../spark/sql/execution/ui/static/spark-sql-viz.js | 2 +-
9 files changed, 113 insertions(+), 9 deletions(-)
diff --git
a/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
b/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
index 9867e69ebd98..b6de9297fdae 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
@@ -316,7 +316,7 @@ function totalDurationStyle(totalGCTime, totalDuration) {
}
function totalDurationColor(totalGCTime, totalDuration) {
- return (totalGCTime > GCTimePercent * totalDuration) ? "white" : "black";
+ return (totalGCTime > GCTimePercent * totalDuration) ? "white" :
"var(--bs-body-color)";
}
var sumOptionalColumns = [3, 4];
diff --git
a/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg
b/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg
new file mode 100644
index 000000000000..fc4f6790218d
--- /dev/null
+++ b/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="68" height="36" viewBox="0 0 68
36">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#E25A1C" d="M62.061 17.243c-.058.123-.085.186-.117.245-.85
1.594-1.698 3.19-2.555 4.78-.087.159-.076.254.042.39 1.352 1.558 2.697 3.122
4.044
4.685.047.054.09.113.108.21-.394-.101-.788-.201-1.181-.304-1.634-.427-3.268-.853-4.9-1.285-.152-.04-.221.003-.297.128-.927
1.528-1.86 3.053-2.792
4.578-.048.08-.1.157-.202.223-.075-.407-.152-.814-.225-1.221l-.777-4.314c-.028-.156-.067-.31-.08-.467-.014-.148-.09-.203-.227-.245-1.925-.596-3.848-1.198-5.772-1.8-.084-.026-.167-.06-.
[...]
+ <path fill="#FFF" d="M59.483 3.841c-1.193.002-2.386.008-3.58.003-.157
0-.246.045-.334.177-1.412 2.122-2.83 4.239-4.248
6.357-.045.068-.093.133-.174.246l-.902-6.767h-3.124c.037.3.069.59.107.88.305
2.296.611 4.594.918 6.89.292 2.195.583 4.39.88 6.585.009.065.053.148.107.183
1.075.69 2.154 1.376 3.232 2.062.016.01.038.011.094.027l-.974-7.324.038-.026
5.113
5.59.136-.772c.121-.698.237-1.396.367-2.092.026-.14-.011-.228-.106-.325-1.094-1.13-2.185-2.263-3.276-3.396l-.147-.159c.035-.055.
[...]
+ <path fill="#FFF" fill-rule="nonzero" d="M62.9
3.859v1.207h-.006l-.48-1.207h-.154l-.48
1.207h-.007V3.86h-.242v1.446h.373l.438-1.099.43 1.099h.37V3.859h-.241zm-2.127
1.253V3.859h-.242v1.253h-.46v.193h1.16v-.193h-.458M16.682 20.339h.72l-.17
1.073-.55-1.073zm.832-.694h-1.196l-.38-.734h-.847l1.868
3.443h.817l.636-3.443h-.785l-.113.734M21.793 21.66h-.426l-.143-.794h.425c.257 0
.463.166.463.48 0 .208-.13.314-.319.314zm-1.032.694h1.12c.585 0
.995-.345.995-.937 0-.744-.534-1.245-1.293-1. [...]
+ </g>
+</svg>
diff --git
a/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
b/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
index d9cb0b8f8192..5b81f4d6bd96 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
+++ b/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
@@ -333,3 +333,40 @@ span.expand-task-assignment-timeline {
list-style: none;
margin-left: 15px;
}
+
+/* Dark mode overrides for vis-timeline container */
+[data-bs-theme="dark"] .vis-timeline {
+ border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-bottom,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-center,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-top,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-left,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-right {
+ border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-text {
+ color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-grid.vis-minor {
+ border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-grid.vis-major {
+ border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-labelset .vis-label {
+ color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-labelset .vis-label .vis-inner {
+ color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-item .vis-item-content {
+ color: var(--bs-body-color);
+}
diff --git a/core/src/main/resources/org/apache/spark/ui/static/webui.css
b/core/src/main/resources/org/apache/spark/ui/static/webui.css
index a5a9965c02c0..69ce54532274 100755
--- a/core/src/main/resources/org/apache/spark/ui/static/webui.css
+++ b/core/src/main/resources/org/apache/spark/ui/static/webui.css
@@ -157,6 +157,7 @@ span.rest-uri {
pre {
background-color: var(--bs-tertiary-bg);
+ color: var(--bs-body-color);
font-size: 12px;
line-height: 18px;
padding: 6px;
diff --git a/core/src/main/resources/org/apache/spark/ui/static/webui.js
b/core/src/main/resources/org/apache/spark/ui/static/webui.js
index dc877d9be55e..6edf2f5e009d 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/webui.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/webui.js
@@ -29,6 +29,45 @@ function setAppBasePath(path) {
}
/* eslint-enable no-unused-vars */
+// Theme toggle (dark/light mode)
+function updateThemeButton(btn, theme) {
+ btn.textContent = "\u25D1";
+ btn.setAttribute("title", theme === "dark" ? "Switch to light mode" :
"Switch to dark mode");
+ // Swap logo for dark/light mode
+ document.querySelectorAll("img.spark-logo").forEach(function(img) {
+ var src = img.getAttribute("src") || "";
+ if (theme === "dark") {
+ img.setAttribute("src", src.replace("spark-logo.svg",
"spark-logo-rev.svg"));
+ } else {
+ img.setAttribute("src", src.replace("spark-logo-rev.svg",
"spark-logo.svg"));
+ }
+ });
+}
+
+function initThemeToggle() {
+ var saved = localStorage.getItem("spark-theme");
+ var theme = saved ||
+ (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" :
"light");
+ document.documentElement.setAttribute("data-bs-theme", theme);
+
+ var btn = document.getElementById("theme-toggle");
+ if (btn) {
+ updateThemeButton(btn, theme);
+ btn.addEventListener("click", function() {
+ var current = document.documentElement.getAttribute("data-bs-theme");
+ var next = current === "dark" ? "light" : "dark";
+ document.documentElement.setAttribute("data-bs-theme", next);
+ localStorage.setItem("spark-theme", next);
+ updateThemeButton(btn, next);
+ });
+ }
+}
+
+// Initialize theme toggle
+$(function() {
+ initThemeToggle();
+});
+
// Persist BS5 collapse state in localStorage
$(function() {
$(document).on("shown.bs.collapse hidden.bs.collapse", ".collapsible-table",
function(e) {
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 d12a7a84daad..55412cac6808 100644
--- a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
+++ b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
@@ -280,9 +280,13 @@ private[spark] object UIUtils extends Logging {
}
val helpButton: Seq[Node] = helpText.map(tooltip(_,
"top")).getOrElse(Seq.empty)
- <html>
+ <html data-bs-theme="light">
<head>
{commonHeaderNodes(request)}
+ <script nonce={CspNonce.get}>{Unparsed(
+ "document.documentElement.setAttribute('data-bs-theme'," +
+ "localStorage.getItem('spark-theme')||" +
+
"(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))")}</script>
<script
nonce={CspNonce.get}>setAppBasePath('{activeTab.basePath}')</script>
{if (showVisualization) vizHeaderNodes(request) else Seq.empty}
{if (useDataTables) dataTablesHeaderNodes(request) else Seq.empty}
@@ -295,7 +299,7 @@ private[spark] object UIUtils extends Logging {
<div class="navbar-header">
<div class="navbar-brand">
<a href={prependBaseUri(request, "/")}>
- <img src={prependBaseUri(request, "/static/spark-logo.svg")}
+ <img class="spark-logo" src={prependBaseUri(request,
"/static/spark-logo.svg")}
alt="Spark Logo" height="36" />
<span class="version">{activeTab.appSparkVersion}</span>
</a>
@@ -312,6 +316,8 @@ private[spark] object UIUtils extends Logging {
<strong title={appName}
class="text-nowrap">{shortAppName}</strong>
<span class="text-nowrap">application UI</span>
</span>
+ <button id="theme-toggle" class="btn btn-sm btn-link
text-decoration-none fs-5 ms-2 p-0"
+ type="button" title="Toggle dark mode"></button>
</div>
</nav>
<div class="container-fluid">
@@ -339,9 +345,13 @@ private[spark] object UIUtils extends Logging {
content: => Seq[Node],
title: String,
useDataTables: Boolean = false): Seq[Node] = {
- <html>
+ <html data-bs-theme="light">
<head>
{commonHeaderNodes(request)}
+ <script nonce={CspNonce.get}>{Unparsed(
+ "document.documentElement.setAttribute('data-bs-theme'," +
+ "localStorage.getItem('spark-theme')||" +
+
"(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))")}</script>
{if (useDataTables) dataTablesHeaderNodes(request) else Seq.empty}
<link rel="shortcut icon"
href={prependBaseUri(request, "/static/spark-logo.svg")}></link>
@@ -353,11 +363,14 @@ private[spark] object UIUtils extends Logging {
<div class="col-12">
<h3 class="align-middle d-inline-block">
<a class="text-decoration-none" href={prependBaseUri(request,
"/")}>
- <img src={prependBaseUri(request, "/static/spark-logo.svg")}
+ <img class="spark-logo" src={prependBaseUri(request,
"/static/spark-logo.svg")}
alt="Spark Logo" height="36" />
<span class="version
me-3">{org.apache.spark.SPARK_VERSION}</span>
</a>
{title}
+ <button id="theme-toggle"
+ class="btn btn-sm btn-link text-decoration-none fs-5
ms-2 p-0 align-middle"
+ type="button" title="Toggle dark mode"></button>
</h3>
</div>
</div>
diff --git a/dev/.rat-excludes b/dev/.rat-excludes
index 88a9e5d91558..e254267fdc69 100644
--- a/dev/.rat-excludes
+++ b/dev/.rat-excludes
@@ -143,4 +143,5 @@
core/src/main/resources/org/apache/spark/ui/static/package.json
testCommitLog
.*\.har
spark-logo.svg
+spark-logo-rev.svg
.nojekyll
diff --git
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
index 66d15dd1d30b..55ba5aa47869 100644
---
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
+++
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
@@ -112,8 +112,8 @@ svg path.linked {
}
#plan-viz-details-panel .card-header {
- background-color: #C3EBFF;
- border-bottom-color: #3EC0FF;
+ background-color: var(--spark-sql-node-fill);
+ border-bottom-color: var(--spark-sql-cluster-stroke);
}
#plan-viz-details-panel .table th,
@@ -128,5 +128,11 @@ svg path.linked {
/* Edge labels showing row counts */
.edgeLabel text {
font-size: 10px;
- fill: #6c757d;
+ fill: var(--bs-secondary-color);
+}
+
+/* SVG text color for dark mode support */
+#plan-viz-graph .node text,
+#plan-viz-graph g.cluster text {
+ fill: var(--bs-body-color);
}
diff --git
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
index 007f2963ce15..584ffa89c94c 100644
---
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
+++
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
@@ -161,7 +161,7 @@ function preprocessGraphLayout(g) {
curve: d3.curveBasis,
label: edgeObj.label || "",
style: "fill: none",
- labelStyle: "font-size: 10px; fill: #6c757d;"
+ labelStyle: "font-size: 10px;"
})
})
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]