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 e3dd094da891 [SPARK-55767][UI] Use Bootstrap 5 Offcanvas for executor
detail panels
e3dd094da891 is described below
commit e3dd094da891f7086b0da5d44c269d7a1d7930d2
Author: Kent Yao <[email protected]>
AuthorDate: Wed Mar 4 09:41:51 2026 +0800
[SPARK-55767][UI] Use Bootstrap 5 Offcanvas for executor detail panels
### What changes were proposed in this pull request?
Replace full-page navigation for Thread Dump and Heap Histogram on the
Executors page with Bootstrap 5 Offcanvas slide-out panels.
**Key features:**
- Thread Dump and Heap Histogram open in a right-side slide-out panel
instead of navigating away
- Flamegraph renders correctly inside the offcanvas (d3/d3-flamegraph
loaded dynamically)
- Panel is **resizable** — drag the left edge to adjust width
- "Open in new tab" button for full-page access
- Loading spinner while content fetches
- Fallback link on fetch failure
### Why are the changes needed?
Part of [SPARK-55760](https://issues.apache.org/jira/browse/SPARK-55760)
(Spark Web UI Modernization). Keeping users on the Executors list while viewing
details improves navigation flow.
### Does this PR introduce _any_ user-facing change?
Yes. Thread Dump / Heap Histogram links now open slide-out panels. Direct
URL access to the detail pages still works.
### How was this patch tested?
Scalastyle, JS lint, compilation pass. Manually verified with live Spark UI.
### Was this patch authored or co-authored using generative AI tooling?
Yes, GitHub Copilot CLI was used.
### Screenshots
<img
src="https://raw.githubusercontent.com/yaooqinn/spark/SPARK-55786-screenshots/02-thread-dump-offcanvas.png"
width="800" alt="Thread dump in offcanvas panel">
*Thread Dump opened in offcanvas panel*
<img
src="https://raw.githubusercontent.com/yaooqinn/spark/SPARK-55786-screenshots/03-flamegraph-offcanvas.png"
width="800" alt="Flamegraph rendering in offcanvas">
*Flamegraph renders inside the offcanvas*
<img
src="https://raw.githubusercontent.com/yaooqinn/spark/SPARK-55786-screenshots/04-heap-histogram-offcanvas.png"
width="800" alt="Heap histogram in offcanvas">
*Heap Histogram in offcanvas panel*
<img
src="https://raw.githubusercontent.com/yaooqinn/spark/SPARK-55786-screenshots/05-resized-offcanvas.png"
width="800" alt="Resized offcanvas panel">
*Panel resized wider by dragging the left edge*
Closes #54589 from yaooqinn/SPARK-55767.
Authored-by: Kent Yao <[email protected]>
Signed-off-by: Kent Yao <[email protected]>
---
.../org/apache/spark/ui/static/executorspage.js | 146 ++++++++++++++++++++-
.../resources/org/apache/spark/ui/static/webui.css | 17 +++
.../org/apache/spark/ui/exec/ExecutorsTab.scala | 12 ++
3 files changed, 172 insertions(+), 3 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 9f7ff6db7494..78a0cb663db1 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
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-/* global $, Mustache */
+/* global $, Mustache, sorttable */
import {
createRESTEndPointForExecutorsPage,
createRESTEndPointForMiscellaneousProcess, createTemplateURI,
@@ -46,6 +46,146 @@ function getHeapHistogramEnabled() {
return heapHistogramEnabled;
}
+function loadScript(src) {
+ return new Promise(function(resolve, reject) {
+ if (document.querySelector('script[src="' + src + '"]')) {
+ resolve();
+ return;
+ }
+ var s = document.createElement('script');
+ s.src = src;
+ s.onload = resolve;
+ s.onerror = reject;
+ document.head.appendChild(s);
+ });
+}
+
+function openDetailOffcanvas(url, title) {
+ var offcanvasEl = document.getElementById('executor-detail-offcanvas');
+ var offcanvasBody =
document.getElementById('executor-detail-offcanvas-body');
+ var offcanvasLabel =
document.getElementById('executor-detail-offcanvas-label');
+ offcanvasLabel.textContent = title;
+ offcanvasBody.innerHTML = '<div class="d-flex justify-content-center p-5">' +
+ '<div class="spinner-border text-primary" role="status">' +
+ '<span class="visually-hidden">Loading...</span></div></div>';
+ var bsOffcanvas = bootstrap.Offcanvas.getOrCreateInstance(offcanvasEl);
+ bsOffcanvas.show();
+ $.get(url, function(html) {
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(html, 'text/html');
+ var container = doc.querySelector('.container-fluid');
+ if (container) {
+ // Strip script/link tags from fetched content before injecting
+ container.querySelectorAll('script, link').forEach(function(el) {
el.remove(); });
+ offcanvasBody.innerHTML = container.innerHTML;
+ } else {
+ offcanvasBody.innerHTML = '<p class="text-danger">Failed to load
content.</p>';
+ }
+ // Add "Open in new tab" link at the top
+ var openLink = document.createElement('div');
+ openLink.className = 'mb-3';
+ openLink.innerHTML = '<a href="' + url + '" target="_blank" ' +
+ 'class="btn btn-sm btn-outline-secondary">' +
+ '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
fill="currentColor" ' +
+ 'class="bi bi-box-arrow-up-right me-1" viewBox="0 0 16 16">' +
+ '<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0
0 0 0 4.5v10A1.5' +
+ ' 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1
0V14.5a.5.5 0 0 1-.5.5h' +
+ '-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5"/>' +
+ '<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0
1h3.793L6.146 9.146' +
+ 'a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"/></svg>' +
+ 'Open in new tab</a>';
+ offcanvasBody.insertBefore(openLink, offcanvasBody.firstChild);
+ // Re-init Bootstrap tooltips in the injected content
+
offcanvasBody.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el)
{
+ new bootstrap.Tooltip(el);
+ });
+ // Re-init sorttable for injected tables
+ if (typeof sorttable !== 'undefined') {
+ offcanvasBody.querySelectorAll('table.sortable').forEach(function(table)
{
+ sorttable.makeSortable(table);
+ });
+ }
+ // Re-init flamegraph if data is present
+ var fgData = offcanvasBody.querySelector('#executor-flamegraph-data');
+ var fgChartEl = offcanvasBody.querySelector('#executor-flamegraph-chart');
+ if (fgData && fgChartEl) {
+ initOffcanvasFlamegraph(fgData, fgChartEl, offcanvasEl);
+ }
+ }).fail(function() {
+ offcanvasBody.innerHTML = '<p class="text-danger">Error loading content. '
+
+ '<a href="' + url + '">Open in new page</a></p>';
+ });
+}
+
+function initOffcanvasFlamegraph(fgData, fgChart, offcanvasEl) {
+ // Load CSS
+ if (!document.querySelector('link[href="/static/d3-flamegraph.css"]')) {
+ var link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = '/static/d3-flamegraph.css';
+ document.head.appendChild(link);
+ }
+ // Load d3 then d3-flamegraph, then render
+ loadScript('/static/d3.min.js').then(function() {
+ return loadScript('/static/d3-flamegraph.min.js');
+ }).then(function() {
+ /* global d3, flamegraph */
+ var width = offcanvasEl.offsetWidth - 60;
+ var chart = flamegraph()
+ .width(width)
+ .cellHeight(18)
+ .transitionEase(d3.easeCubic)
+ .sort(true)
+ .title('');
+ var jsonData = JSON.parse(fgData.textContent.trim());
+ d3.select(fgChart).datum(jsonData).call(chart);
+ // Toggle visibility
+ var header = document.getElementById('executor-flamegraph-header');
+ if (header) {
+ $(header).off('click').on('click', function() {
+ var arrow = $('#executor-flamegraph-arrow');
+ arrow.toggleClass('arrow-open arrow-closed');
+ $(fgChart).toggle(arrow.hasClass('arrow-open'));
+ });
+ }
+ });
+}
+
+// Delegated click handler for offcanvas links (CSP blocks inline onclick)
+$(document).on('click', '.offcanvas-link', function(e) {
+ e.preventDefault();
+ var url = $(this).data('detail-url');
+ var title = $(this).data('detail-title');
+ openDetailOffcanvas(url, title);
+});
+
+// Drag-to-resize offcanvas from left edge
+$(document).ready(function() {
+ var handle = document.getElementById('offcanvas-resize-handle');
+ var offcanvas = document.getElementById('executor-detail-offcanvas');
+ if (!handle || !offcanvas) return;
+ var startX, startWidth;
+ handle.addEventListener('mousedown', function(e) {
+ e.preventDefault();
+ startX = e.clientX;
+ startWidth = offcanvas.offsetWidth;
+ handle.classList.add('resizing');
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ });
+ function onMouseMove(e) {
+ var newWidth = startWidth - (e.clientX - startX);
+ newWidth = Math.max(300, Math.min(newWidth, window.innerWidth - 50));
+ offcanvas.style.width = newWidth + 'px';
+ offcanvas.style.maxWidth = 'none';
+ }
+ function onMouseUp() {
+ handle.classList.remove('resizing');
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ }
+});
+
function formatLossReason(removeReason) {
if (removeReason) {
return removeReason
@@ -589,14 +729,14 @@ $(document).ready(function () {
name: 'threadDumpCol',
data: function (row) { return row.isActive ? row.id : '' },
render: function (data, type) {
- return data != '' && type === 'display' ? ("<a
href='threadDump/?executorId=" + data + "'>Thread Dump</a>" ) : data;
+ return data != '' && type === 'display' ? ("<a href='#'
class='offcanvas-link' data-detail-url='threadDump/?executorId=" +
encodeURIComponent(data) + "' data-detail-title='Thread Dump for Executor " +
data + "'>Thread Dump</a>" ) : data;
}
},
{
name: 'heapHistogramCol',
data: function (row) { return row.isActive ? row.id : '' },
render: function (data, type) {
- return data != '' && type === 'display' ? ("<a
href='heapHistogram/?executorId=" + data + "'>Heap Histogram</a>") : data;
+ return data != '' && type === 'display' ? ("<a href='#'
class='offcanvas-link' data-detail-url='heapHistogram/?executorId=" +
encodeURIComponent(data) + "' data-detail-title='Heap Histogram for Executor "
+ data + "'>Heap Histogram</a>") : data;
}
},
{
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 0dd11915cf3b..6f61389dc59e 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
@@ -474,3 +474,20 @@ a.downloadbutton {
bottom: 50%;
transform: translateY(50%);
}
+
+/* Offcanvas resize handle */
+.offcanvas-resize-handle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 6px;
+ height: 100%;
+ cursor: ew-resize;
+ background: transparent;
+ z-index: 1060;
+ transition: background-color 0.15s;
+}
+.offcanvas-resize-handle:hover,
+.offcanvas-resize-handle.resizing {
+ background-color: rgba(13, 110, 253, 0.3);
+}
diff --git a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
index 5b09fc6eb04a..f287897d6741 100644
--- a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
+++ b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
@@ -67,6 +67,18 @@ private[ui] class ExecutorsPage(
val content =
{
<div id="active-executors"></div> ++
+ <div class="offcanvas offcanvas-end" tabindex="-1"
id="executor-detail-offcanvas"
+ aria-labelledby="executor-detail-offcanvas-label"
+ style="width: 60vw; max-width: 900px;">
+ <div class="offcanvas-resize-handle"
id="offcanvas-resize-handle"></div>
+ <div class="offcanvas-header">
+ <h5 class="offcanvas-title"
id="executor-detail-offcanvas-label"></h5>
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas"
+ aria-label="Close"></button>
+ </div>
+ <div class="offcanvas-body" id="executor-detail-offcanvas-body">
+ </div>
+ </div> ++
<script type="module" src={UIUtils.prependBaseUri(request,
"/static/utils.js")}></script> ++
<script type="module"
src={UIUtils.prependBaseUri(request,
"/static/executorspage.js")}></script> ++
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]