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]

Reply via email to