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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 6c3414a  ui: eBPF process picker — anchored popout + flat attribute 
rows in expand panel
6c3414a is described below

commit 6c3414a9b5921bcb622f19f31cfb3e85ff1cb42e
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 08:44:53 2026 +0800

    ui: eBPF process picker — anchored popout + flat attribute rows in expand 
panel
    
    Two changes to the process-picker UX:
    
    1. Popout instead of inline panel.
       The picker used to slide in between the filter banner and the
       flamegraph, shoving the result down by ~220px and forcing the
       operator to choose between "see the picker" or "see the data". Move
       it to a Teleport-to-<body> popout anchored to the trigger button's
       viewport rect. Width caps at 880px (with viewport-edge clamp), max
       height 480px with internal scroll; flips above the button when there
       isn't 240px of room below. Wired:
    
       - `pickerBtnEl` template ref → BCR drives `pickerPos {top,left,width}`
       - `anchorPicker()` recomputes on open + on window resize
       - document `mousedown` outside the trigger / popout → close
       - document `keydown` Esc → close
       - listeners attached only while the picker is open; defensive
         onBeforeUnmount removal in case the view unmounts mid-open
    
       The picker head now has a search input, a "<n> pinned" count chip,
       and a × close button. The proc-table inside the popout uses
       `position: sticky` on its head row so column labels stay visible
       while the body scrolls.
    
    2. Flatten Attributes in the expand panel.
       The per-row expand panel used to nest `name=value` pairs under an
       "Attributes" label, producing a two-level hierarchy (Attributes →
       host_ip → value). The names themselves already read as attribute
       keys, so the wrapper added a level for no payoff. Promote each
       attribute to its own first-level dl row, matching the booster-ui
       process detail card and the rest of the rows (Process / Service /
       Instance / Agent / etc).
    
       Drops the now-unused `.pe-attrs`, `.pe-attr`, `.pe-attr-k`,
       `.pe-attr-v` style block.
---
 .../src/layer/profiling/LayerEBPFProfilingView.vue | 342 +++++++++++++++------
 1 file changed, 241 insertions(+), 101 deletions(-)

diff --git a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
index ab87a5a..a6de8e9 100644
--- a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
@@ -28,7 +28,7 @@
     └───────────┴────────────────────────────────────────────────┘
 -->
 <script setup lang="ts">
-import { computed, reactive, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { useLayers } from '@/shell/useLayers';
 import { useSelectedService } from '@/layer/useSelectedService';
@@ -111,11 +111,63 @@ const highlightTop = ref(true);
 // Process picker state
 const processSearch = ref('');
 const showProcessPicker = ref(false);
-// Per-row expand/fold for the process picker. Rows render the same
-// truncated three-column shape by default; expanding shows every
-// attribute uncropped (the most useful column — `command_line`,
-// `container.id`, agent metadata — is too wide to fit a single grid
-// cell). Tracked as a Set to keep toggle O(1).
+// Trigger button + computed popout coordinates. The picker is teleported
+// to <body> on open so it can overflow the layer-tab card without
+// pushing the flamegraph down. Position is anchored to the trigger
+// button's viewport rect (BCR) at open-time + on resize; we don't
+// re-anchor on scroll because the toolbar sits inside the page's own
+// scroller and tracking that would cost a listener per repaint for
+// negligible UX gain — the picker just closes if the operator scrolls
+// it offscreen.
+const pickerBtnEl = ref<HTMLElement | null>(null);
+const pickerPos = reactive({ top: 0, left: 0, width: 0 });
+const PICKER_WIDTH = 880;
+const PICKER_MAX_HEIGHT = 480;
+function anchorPicker(): void {
+  const el = pickerBtnEl.value;
+  if (!el) return;
+  const r = el.getBoundingClientRect();
+  // Default: drop down below the button. If there's not enough room
+  // below, flip up so the body fits without being clipped at the
+  // viewport bottom. 12px gap from the button edge.
+  const gap = 8;
+  const spaceBelow = window.innerHeight - r.bottom - gap;
+  const wantDown = spaceBelow >= 240 || spaceBelow >= r.top - gap;
+  pickerPos.top = wantDown
+    ? r.bottom + gap
+    : Math.max(8, r.top - gap - PICKER_MAX_HEIGHT);
+  // Right-align so the popout sits under the trigger's right edge,
+  // matching standard dropdown anchoring. Clamp to viewport (8px gutter).
+  const wantLeft = Math.min(
+    r.right - PICKER_WIDTH,
+    window.innerWidth - PICKER_WIDTH - 8,
+  );
+  pickerPos.left = Math.max(8, wantLeft);
+  pickerPos.width = Math.min(PICKER_WIDTH, window.innerWidth - 16);
+}
+function openProcessPicker(): void {
+  showProcessPicker.value = true;
+  // Schedule the anchor compute after the button's reactive class/text
+  // flip lands in the DOM, so the BCR is the post-render rect.
+  void Promise.resolve().then(anchorPicker);
+}
+function closeProcessPicker(): void {
+  showProcessPicker.value = false;
+}
+function onPickerOutsideMouseDown(ev: MouseEvent): void {
+  if (!showProcessPicker.value) return;
+  const target = ev.target as Node;
+  // Trigger button toggles via its own @click; ignore.
+  if (pickerBtnEl.value && pickerBtnEl.value.contains(target)) return;
+  // Click inside the popout itself stays open.
+  const popout = document.querySelector('.process-picker-pop');
+  if (popout && popout.contains(target)) return;
+  closeProcessPicker();
+}
+function onPickerKeyDown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && showProcessPicker.value) closeProcessPicker();
+}
+// Per-row expand/fold inside the popout. Tracked as a Set for O(1) toggle.
 const expandedProcessIds = ref<Set<string>>(new Set());
 function toggleProcessExpanded(id: string): void {
   const next = new Set(expandedProcessIds.value);
@@ -313,6 +365,26 @@ watch(selectedProcessIds, () => {
   void runAnalyze();
 }, { deep: true });
 
+// Wire the picker's outside-click + ESC + resize listeners only while
+// it's open. Symmetric attach/detach keeps the document free of stray
+// listeners when the picker is closed (cheap, but the right shape).
+watch(showProcessPicker, (open) => {
+  if (open) {
+    window.addEventListener('resize', anchorPicker);
+    document.addEventListener('mousedown', onPickerOutsideMouseDown);
+    document.addEventListener('keydown', onPickerKeyDown);
+  } else {
+    window.removeEventListener('resize', anchorPicker);
+    document.removeEventListener('mousedown', onPickerOutsideMouseDown);
+    document.removeEventListener('keydown', onPickerKeyDown);
+  }
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', anchorPicker);
+  document.removeEventListener('mousedown', onPickerOutsideMouseDown);
+  document.removeEventListener('keydown', onPickerKeyDown);
+});
+
 async function submitNewTask(): Promise<void> {
   if (!selectedId.value) {
     newTaskError.value = 'Pick a service first';
@@ -489,8 +561,15 @@ function toggleNewTaskLabel(l: string): void {
         </div>
         <div class="tb-block grow">
           <label class="lbl">Processes ({{ selectedProcessIds.length }} 
pinned)</label>
-          <button class="btn-secondary" @click="showProcessPicker = 
!showProcessPicker">
-            {{ showProcessPicker ? 'Hide picker' : 'Pick processes' }}
+          <button
+            ref="pickerBtnEl"
+            class="btn-secondary"
+            :class="{ open: showProcessPicker }"
+            :aria-expanded="showProcessPicker"
+            aria-haspopup="dialog"
+            @click="showProcessPicker ? closeProcessPicker() : 
openProcessPicker()"
+          >
+            {{ showProcessPicker ? 'Close picker' : 'Pick processes' }}
           </button>
         </div>
         <button
@@ -509,84 +588,110 @@ function toggleNewTaskLabel(l: string): void {
         — {{ schedules.length }} schedule{{ schedules.length === 1 ? '' : 's' 
}}
       </div>
 
-      <div v-if="showProcessPicker" class="process-picker">
-        <input
-          v-model="processSearch"
-          placeholder="Search by name / instance / command line…"
-          class="ti-input wide"
-        />
-        <div class="proc-table">
-          <div class="ph">
-            <div class="cc cc-sel"></div>
-            <div class="cc cc-name">Process</div>
-            <div class="cc cc-inst">Instance</div>
-            <div class="cc cc-attrs">Attributes</div>
-            <div class="cc cc-exp"></div>
+      <!-- Picker popout — teleported to <body> so it overlays the
+           layer-tab card instead of pushing the flamegraph down. Anchor
+           rect lives on `pickerPos`; click-outside + ESC dismiss are
+           wired in via setup-level watchers. -->
+      <Teleport to="body">
+        <div
+          v-if="showProcessPicker"
+          class="process-picker-pop sw-card"
+          role="dialog"
+          aria-label="Process picker"
+          :style="{
+            top: pickerPos.top + 'px',
+            left: pickerPos.left + 'px',
+            width: pickerPos.width + 'px',
+          }"
+        >
+          <div class="pp-head">
+            <input
+              v-model="processSearch"
+              placeholder="Search by name / instance / command line…"
+              class="ti-input wide"
+              autofocus
+            />
+            <span class="pp-count">{{ selectedProcessIds.length }} 
pinned</span>
+            <!-- Textual ×, not an icon glyph (no SVG asset exists for
+                 close and a one-off SVG would violate CLAUDE.md's
+                 single-icon-component rule). Same shape as the prior
+                 dialog close buttons across the codebase. -->
+            <button class="pp-close" aria-label="Close picker" title="Close 
(Esc)" @click="closeProcessPicker">×</button>
           </div>
-          <div v-if="!filteredProcesses.length" class="empty">No matches.</div>
-          <template v-for="p in filteredProcesses" :key="p.id">
-            <div
-              class="pr"
-              :class="{ on: selectedProcessIds.includes(p.id) }"
-              @click="toggleProcessId(p.id)"
-            >
-              <div class="cc cc-sel" @click.stop>
-                <input
-                  type="checkbox"
-                  :checked="selectedProcessIds.includes(p.id)"
-                  :aria-label="`Pin process ${p.name}`"
-                  @change="toggleProcessId(p.id)"
-                />
-              </div>
-              <div class="cc cc-name" :title="p.name">{{ p.name }}</div>
-              <div class="cc cc-inst" :title="p.instanceName ?? ''">{{ 
p.instanceName }}</div>
-              <div class="cc cc-attrs" :title="attrLine(p)">{{ attrLine(p) 
}}</div>
-              <button
-                type="button"
-                class="cc cc-exp pr-caret"
-                :class="{ open: expandedProcessIds.has(p.id) }"
-                :aria-expanded="expandedProcessIds.has(p.id)"
-                :aria-label="expandedProcessIds.has(p.id) ? 'Collapse details' 
: 'Expand details'"
-                @click.stop="toggleProcessExpanded(p.id)"
-              >
-                <Icon name="caret" :size="10" />
-              </button>
+          <div class="proc-table">
+            <div class="ph">
+              <div class="cc cc-sel"></div>
+              <div class="cc cc-name">Process</div>
+              <div class="cc cc-inst">Instance</div>
+              <div class="cc cc-attrs">Attributes</div>
+              <div class="cc cc-exp"></div>
             </div>
-            <div v-if="expandedProcessIds.has(p.id)" class="pr-expand">
-              <dl class="pe-rows">
-                <div class="pe-row"><dt>Process</dt><dd class="mono">{{ p.name 
}}</dd></div>
-                <div v-if="p.serviceName" class="pe-row">
-                  <dt>Service</dt><dd class="mono">{{ p.serviceName }}</dd>
-                </div>
-                <div v-if="p.instanceName" class="pe-row">
-                  <dt>Instance</dt><dd class="mono">{{ p.instanceName }}</dd>
-                </div>
-                <div v-if="p.agentId" class="pe-row">
-                  <dt>Agent</dt><dd class="mono">{{ p.agentId }}</dd>
-                </div>
-                <div v-if="p.detectType" class="pe-row">
-                  <dt>Detect type</dt><dd class="mono">{{ p.detectType }}</dd>
-                </div>
-                <div v-if="(p.labels ?? []).length" class="pe-row">
-                  <dt>Labels</dt>
-                  <dd>
-                    <span v-for="l in p.labels" :key="l" class="pe-chip">{{ l 
}}</span>
-                  </dd>
-                </div>
-                <div v-if="(p.attributes ?? []).length" class="pe-row 
pe-attrs">
-                  <dt>Attributes</dt>
-                  <dd>
-                    <div v-for="a in p.attributes" :key="a.name" 
class="pe-attr">
-                      <span class="pe-attr-k">{{ a.name }}</span>
-                      <span class="pe-attr-v mono">{{ a.value }}</span>
-                    </div>
-                  </dd>
+            <div v-if="!filteredProcesses.length" class="empty">No 
matches.</div>
+            <template v-for="p in filteredProcesses" :key="p.id">
+              <div
+                class="pr"
+                :class="{ on: selectedProcessIds.includes(p.id) }"
+                @click="toggleProcessId(p.id)"
+              >
+                <div class="cc cc-sel" @click.stop>
+                  <input
+                    type="checkbox"
+                    :checked="selectedProcessIds.includes(p.id)"
+                    :aria-label="`Pin process ${p.name}`"
+                    @change="toggleProcessId(p.id)"
+                  />
                 </div>
-              </dl>
-            </div>
-          </template>
+                <div class="cc cc-name" :title="p.name">{{ p.name }}</div>
+                <div class="cc cc-inst" :title="p.instanceName ?? ''">{{ 
p.instanceName }}</div>
+                <div class="cc cc-attrs" :title="attrLine(p)">{{ attrLine(p) 
}}</div>
+                <button
+                  type="button"
+                  class="cc cc-exp pr-caret"
+                  :class="{ open: expandedProcessIds.has(p.id) }"
+                  :aria-expanded="expandedProcessIds.has(p.id)"
+                  :aria-label="expandedProcessIds.has(p.id) ? 'Collapse 
details' : 'Expand details'"
+                  @click.stop="toggleProcessExpanded(p.id)"
+                >
+                  <Icon name="caret" :size="10" />
+                </button>
+              </div>
+              <div v-if="expandedProcessIds.has(p.id)" class="pr-expand">
+                <dl class="pe-rows">
+                  <div class="pe-row"><dt>Process</dt><dd class="mono">{{ 
p.name }}</dd></div>
+                  <div v-if="p.serviceName" class="pe-row">
+                    <dt>Service</dt><dd class="mono">{{ p.serviceName }}</dd>
+                  </div>
+                  <div v-if="p.instanceName" class="pe-row">
+                    <dt>Instance</dt><dd class="mono">{{ p.instanceName }}</dd>
+                  </div>
+                  <div v-if="p.agentId" class="pe-row">
+                    <dt>Agent</dt><dd class="mono">{{ p.agentId }}</dd>
+                  </div>
+                  <div v-if="p.detectType" class="pe-row">
+                    <dt>Detect type</dt><dd class="mono">{{ p.detectType 
}}</dd>
+                  </div>
+                  <div v-if="(p.labels ?? []).length" class="pe-row">
+                    <dt>Labels</dt>
+                    <dd>
+                      <span v-for="l in p.labels" :key="l" class="pe-chip">{{ 
l }}</span>
+                    </dd>
+                  </div>
+                  <!-- Attributes flattened: each `name=value` is a
+                       first-level dl row, matching booster-ui's process
+                       detail card. Nesting them under an "Attributes"
+                       label added a hierarchy level for no payoff — the
+                       names (`host_ip`, `container.id`, `command_line`)
+                       already read as attributes. -->
+                  <div v-for="a in p.attributes ?? []" :key="`attr-${a.name}`" 
class="pe-row">
+                    <dt>{{ a.name }}</dt>
+                    <dd class="mono">{{ a.value }}</dd>
+                  </div>
+                </dl>
+              </div>
+            </template>
+          </div>
         </div>
-      </div>
+      </Teleport>
 
       <div class="result">
         <div v-if="analyzeTip" class="tip">{{ analyzeTip }}</div>
@@ -940,13 +1045,6 @@ function toggleNewTaskLabel(l: string): void {
   font-weight: 600;
 }
 
-.process-picker {
-  border-bottom: 1px solid var(--sw-line);
-  background: var(--sw-bg-1);
-  padding: 8px 12px;
-  max-height: 220px;
-  overflow: auto;
-}
 .ti-input,
 .ti-input.wide {
   background: var(--sw-bg-2);
@@ -960,7 +1058,6 @@ function toggleNewTaskLabel(l: string): void {
 }
 .ti-input.wide {
   width: 100%;
-  margin-bottom: 8px;
 }
 .proc-table {
   border: 1px solid var(--sw-line);
@@ -1067,21 +1164,64 @@ function toggleNewTaskLabel(l: string): void {
   margin: 0 4px 4px 0;
   font-size: 10.5px;
 }
-.pe-attrs dd { display: flex; flex-direction: column; gap: 2px; }
-.pe-attr {
-  display: grid;
-  grid-template-columns: minmax(130px, max-content) 1fr;
+/* Popout shell — teleported to <body>; Vue scoped CSS data-v-X attrs
+ * still match because teleport preserves the component's attribute
+ * assignment. Fixed-positioned, max-height capped so very large process
+ * lists scroll inside the popout instead of overflowing the viewport. */
+.process-picker-pop {
+  position: fixed;
+  z-index: 9000;
+  max-height: 480px;
+  display: flex;
+  flex-direction: column;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5);
+  overflow: hidden;
+}
+.pp-head {
+  display: flex;
+  align-items: center;
   gap: 10px;
-  align-items: baseline;
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
 }
-.pe-attr-k {
-  color: var(--sw-fg-2);
-  font-family: var(--sw-mono, monospace);
+.pp-head .ti-input.wide { flex: 1 1 auto; margin-bottom: 0; }
+.pp-count {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
 }
-.pe-attr-v {
+.pp-close {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  line-height: 1;
+  padding: 0 6px;
+  border-radius: 3px;
+  cursor: pointer;
+}
+.pp-close:hover {
+  background: var(--sw-bg-3);
   color: var(--sw-fg-0);
-  overflow-wrap: anywhere;
-  word-break: break-all;
+}
+/* The proc-table inside the popout grows / scrolls instead of pushing
+ * the popout taller — the head row stays sticky at the top so column
+ * labels remain visible while the body scrolls. */
+.process-picker-pop .proc-table {
+  flex: 1 1 auto;
+  overflow: auto;
+  border: none;
+  border-radius: 0;
+}
+.process-picker-pop .ph {
+  position: sticky;
+  top: 0;
+  z-index: 1;
 }
 
 .result {

Reply via email to