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

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

commit 29a750b735932ab9542ca9f70ac71318e1ae3797
Author: Wu Sheng <[email protected]>
AuthorDate: Fri Jun 26 22:16:10 2026 +0800

    fix(logs,topology,boxes): manual-fire logs, Escape-to-close everywhere, 
popout + a11y fixes
    
    Logs query is now fully manual like the Traces tab: editing conditions 
stages without fetching, the global auto-refresh ticker subscription is 
removed, and the whole results stack stays behind a "Pick your conditions, then 
click Run query." prompt until the operator runs it. Conditions-bar fields get 
name/autocomplete (fixes the browser autofill warning).
    
    Escape now closes every dismissible box: a shared useEscapeToClose 
composable is wired into the 12 popouts/drawers/modals/panels that lacked it 
(span-detail, deployment node/edge, inspect + overview drawers, the three 
profiling modals, the pipeline-stage drawer, the alarm window editor) and the 
topology node/edge detail panel.
    
    Log entry popout: jumping to a trace now closes the popout first (so the 
trace popout is not hidden behind it), and Copy shows a "Copied" confirmation. 
Topology call-detail header no longer overlaps its action buttons with long 
namespace names.
    
    Also tidies two refactor artifacts: the stale NUL-byte eslint override is 
removed, and the shared EndpointCombo no longer re-narrows the endpoint search 
to the picked value.
---
 apps/ui/eslint.config.mjs                          | Bin 3682 -> 3146 bytes
 .../src/components/primitives/useEscapeToClose.ts  |  44 ++++++
 .../overview-templates/OverviewWidgetDrawer.vue    |   3 +
 apps/ui/src/features/alarms/AlarmWindowPicker.vue  |   3 +
 apps/ui/src/features/infra-3d/PipelineTimeline.vue |   3 +
 .../operate/inspect/InspectCatalogDrawer.vue       |   6 +
 apps/ui/src/layer/_shared/EndpointCombo.vue        |   4 +-
 apps/ui/src/layer/_shared/useEndpointCombo.ts      |  22 ++-
 apps/ui/src/layer/logs/LayerLogsView.vue           | 152 ++++++++++++++++-----
 apps/ui/src/layer/logs/useLayerLogs.ts             |  10 +-
 apps/ui/src/layer/profiling/NewEBPFTaskModal.vue   |   6 +
 .../layer/profiling/NewTraceProfileTaskModal.vue   |   6 +
 .../profiling/TraceProfileTaskDetailModal.vue      |   8 +-
 .../src/layer/service-map/DeploymentEdgePanel.vue  |   3 +
 .../layer/service-map/DeploymentNodePopover.vue    |   3 +
 .../src/layer/service-map/LayerServiceMapView.vue  |   9 ++
 .../src/layer/service-map/TopologyDetailPanels.vue |   5 +-
 apps/ui/src/render/widgets/LogDetailPopout.vue     |  17 ++-
 apps/ui/src/render/widgets/SpanDetailModal.vue     |   5 +-
 .../src/render/widgets/ZipkinTraceDetailCard.vue   |   2 +
 20 files changed, 269 insertions(+), 42 deletions(-)

diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs
index 11bd268..d8b006d 100644
Binary files a/apps/ui/eslint.config.mjs and b/apps/ui/eslint.config.mjs differ
diff --git a/apps/ui/src/components/primitives/useEscapeToClose.ts 
b/apps/ui/src/components/primitives/useEscapeToClose.ts
new file mode 100644
index 0000000..590c400
--- /dev/null
+++ b/apps/ui/src/components/primitives/useEscapeToClose.ts
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+import { onBeforeUnmount, onMounted } from 'vue';
+
+/**
+ * Close a dismissible box (popout / modal / drawer / side panel / popover)
+ * on the Escape key — the keyboard counterpart to its × / backdrop click.
+ *
+ * Pass an `isOpen` getter so the listener is a no-op while the box is shut
+ * (and so it reads the LATEST open state on every keypress — a prop, a
+ * ref, a store flag); `close` runs only when open. The window listener is
+ * torn down on unmount.
+ *
+ *   useEscapeToClose(() => props.show, () => emit('close'));
+ *   useEscapeToClose(() => openStage.value !== null, () => (openStage.value = 
null));
+ *
+ * Boxes that nest another dismissible box (a span panel inside a trace
+ * popout) own a bespoke two-level handler instead — this is for the common
+ * single-level case.
+ */
+export function useEscapeToClose(isOpen: () => boolean, close: () => void): 
void {
+  function onKey(e: KeyboardEvent): void {
+    if (e.key !== 'Escape') return;
+    if (!isOpen()) return;
+    close();
+  }
+  onMounted(() => window.addEventListener('keydown', onKey));
+  onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
+}
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
index 2265967..3e5643a 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewWidgetDrawer.vue
@@ -29,6 +29,7 @@
 <script setup lang="ts">
 import type { OverviewDashboard, OverviewKpi, OverviewWidget } from 
'@skywalking-horizon-ui/api-client';
 import MqeExpressionInput from 
'@/features/admin/_shared/MqeExpressionInput.vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import { META_SEL } from './constants';
 
 defineProps<{
@@ -46,6 +47,8 @@ const emit = defineEmits<{
   'update:description': [v: string];
 }>();
 
+useEscapeToClose(() => true, () => emit('close'));
+
 function widgetKindLabel(type: OverviewWidget['type']): string {
   switch (type) {
     case 'metric-composite': return 'Composite metrics';
diff --git a/apps/ui/src/features/alarms/AlarmWindowPicker.vue 
b/apps/ui/src/features/alarms/AlarmWindowPicker.vue
index 9a9ef07..960ea2e 100644
--- a/apps/ui/src/features/alarms/AlarmWindowPicker.vue
+++ b/apps/ui/src/features/alarms/AlarmWindowPicker.vue
@@ -26,11 +26,14 @@
 -->
 <script setup lang="ts">
 import { useI18n } from 'vue-i18n';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import { PRESETS, MAX_CUSTOM_MS, type AlarmWindow } from './useAlarmWindow';
 
 const { t } = useI18n();
 const props = defineProps<{ window: AlarmWindow; part: 'buttons' | 'editor' 
}>();
 const w = props.window;
+
+useEscapeToClose(() => w.customOpen.value, () => w.closeCustom());
 </script>
 
 <template>
diff --git a/apps/ui/src/features/infra-3d/PipelineTimeline.vue 
b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
index 928c5bb..4f4eb20 100644
--- a/apps/ui/src/features/infra-3d/PipelineTimeline.vue
+++ b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
@@ -32,6 +32,7 @@
 <script setup lang="ts">
 import { computed, onMounted, onUnmounted, ref } from 'vue';
 import type { DeepReadonly } from 'vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import type { PipelineStageId, StageState } from 
'./composables/useInfra3dPipeline';
 
 // The pipeline composable exposes `readonly()` wrapped state; we
@@ -70,6 +71,8 @@ const emit = defineEmits<{
 
 const openStage = ref<PipelineStageId | null>(null);
 
+useEscapeToClose(() => openStage.value !== null, () => { openStage.value = 
null; });
+
 function toggleStage(id: PipelineStageId): void {
   openStage.value = openStage.value === id ? null : id;
 }
diff --git a/apps/ui/src/features/operate/inspect/InspectCatalogDrawer.vue 
b/apps/ui/src/features/operate/inspect/InspectCatalogDrawer.vue
index f82486c..95a16d0 100644
--- a/apps/ui/src/features/operate/inspect/InspectCatalogDrawer.vue
+++ b/apps/ui/src/features/operate/inspect/InspectCatalogDrawer.vue
@@ -36,6 +36,7 @@ import {
 import type { InspectCatalogEntry } from '@/api/client';
 import Btn from '@/components/primitives/Btn.vue';
 import Pill from '@/components/primitives/Pill.vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import {
   FOREIGN_SCOPES,
   INSPECT_SOURCES,
@@ -60,6 +61,11 @@ const emit = defineEmits<{
 
 const { t } = useI18n({ useScope: 'global' });
 
+useEscapeToClose(
+  () => true,
+  () => emit('close'),
+);
+
 interface FileNode {
   source: Source;
   file: string;
diff --git a/apps/ui/src/layer/_shared/EndpointCombo.vue 
b/apps/ui/src/layer/_shared/EndpointCombo.vue
index 88be33c..1d6a149 100644
--- a/apps/ui/src/layer/_shared/EndpointCombo.vue
+++ b/apps/ui/src/layer/_shared/EndpointCombo.vue
@@ -53,7 +53,7 @@ const combo = useEndpointCombo();
 watch(combo.query, (q) => emit('update:query', q));
 
 function pick(name: string): void {
-  combo.searchInput.value = name;
+  combo.setDisplay(name);
   combo.open.value = false;
   emit('pick', name);
 }
@@ -68,6 +68,8 @@ function clear(): void {
     <input
       v-model="combo.searchInput.value"
       type="text"
+      name="endpoint-search"
+      autocomplete="off"
       class="cf-input"
       :placeholder="selected ?? placeholder"
       @focus="combo.open.value = true"
diff --git a/apps/ui/src/layer/_shared/useEndpointCombo.ts 
b/apps/ui/src/layer/_shared/useEndpointCombo.ts
index 0bb649b..bc294c1 100644
--- a/apps/ui/src/layer/_shared/useEndpointCombo.ts
+++ b/apps/ui/src/layer/_shared/useEndpointCombo.ts
@@ -33,6 +33,11 @@ export interface EndpointCombo {
   open: Ref<boolean>;
   el: Ref<HTMLElement | null>;
   reset: () => void;
+  /** Set the input's displayed text WITHOUT re-running the debounced
+   *  search. Picking an endpoint writes its name into the input as a
+   *  label; routing that back through `query` would re-narrow the OAP
+   *  list to just the picked row instead of preserving the prior search. */
+  setDisplay: (value: string) => void;
 }
 
 export function useEndpointCombo(opts: { debounceMs?: number } = {}): 
EndpointCombo {
@@ -43,8 +48,15 @@ export function useEndpointCombo(opts: { debounceMs?: number 
} = {}): EndpointCo
   const el = ref<HTMLElement | null>(null);
 
   let timer: ReturnType<typeof setTimeout> | null = null;
+  let skipDebounce = false;
   watch(searchInput, (v) => {
     if (timer) clearTimeout(timer);
+    // `setDisplay` (a pick writing its label back into the input) sets this
+    // so the search query is left untouched — see the interface doc.
+    if (skipDebounce) {
+      skipDebounce = false;
+      return;
+    }
     timer = setTimeout(() => {
       query.value = v.trim();
     }, debounceMs);
@@ -63,10 +75,18 @@ export function useEndpointCombo(opts: { debounceMs?: 
number } = {}): EndpointCo
   });
 
   function reset(): void {
+    if (timer) clearTimeout(timer);
+    skipDebounce = false;
     searchInput.value = '';
     query.value = '';
     open.value = false;
   }
 
-  return { searchInput, query, open, el, reset };
+  function setDisplay(value: string): void {
+    if (timer) clearTimeout(timer);
+    skipDebounce = searchInput.value !== value;
+    searchInput.value = value;
+  }
+
+  return { searchInput, query, open, el, reset, setDisplay };
 }
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index dd6399f..a3d9a7d 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -27,7 +27,7 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
-import type { LayerDef, LogRow } from '@/api/client';
+import type { LayerDef, LogRow, LogTagFilter } from '@/api/client';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useLayerLogs, useLayerLogFacets } from '@/layer/logs/useLayerLogs';
 import { useLayerInstances } from '@/layer/useLayerInstances';
@@ -38,7 +38,7 @@ import { useSelectedInstance } from 
'@/layer/useSelectedInstance';
 import { useSelectedEndpoint } from '@/layer/useSelectedEndpoint';
 import { useLayerServiceName } from '@/layer/useLayerServiceName';
 import { useSetupStore } from '@/state/setup';
-import { useTracePopout } from '@/layer/traces/useTracePopout';
+import { useTracePopout, TRACE_POPOUT_QUERY } from 
'@/layer/traces/useTracePopout';
 import { useDensityBins } from '@/layer/_shared/useDensityBins';
 import { useLogTimeRange, TIME_RANGE_PRESETS, CUSTOM_RANGE_SENTINEL } from 
'@/layer/logs/useLogTimeRange';
 import { useLogTagConditions } from '@/layer/logs/useLogTagConditions';
@@ -167,7 +167,7 @@ const endpointIdForQuery = computed<string | null>(() => 
selectedEndpointObj.val
 // explicit input on the conditions bar. The URL takes precedence so
 // shared / bookmarked URLs always restore the same view.
 const traceIdParam = computed(() => {
-  const v = route.query.traceId;
+  const v = route.query[TRACE_POPOUT_QUERY];
   return typeof v === 'string' && v.length > 0 ? v : null;
 });
 // Trace ID — bound directly to the input. Each keystroke updates the
@@ -207,29 +207,87 @@ const {
 const { tagInput, customTags, selectedLevel, allTags, addTagFilter, 
removeTagFilter, toggleLevel } =
   useLogTagConditions(page);
 
+// ── Manual-fire (applied snapshot) ─────────────────────────────────
+// The log stream + facet queries read these APPLIED conditions, not the
+// live draft refs, so editing instance / endpoint / trace-id / tags /
+// time stages the query without firing it. `applyConditions()` commits
+// the current draft; it runs on the initial service load, on each
+// "Run query", and on a service switch (a context change, not a filter
+// edit). `page`/`pageSize` stay live; there is no periodic refresh.
+interface AppliedLogConditions {
+  service: string | null;
+  instanceId: string | null;
+  endpointId: string | null;
+  traceId: string | null;
+  keywords: string[];
+  tags: LogTagFilter[];
+  windowMinutes: number;
+  startTime: string | null;
+  endTime: string | null;
+}
+function snapshotConditions(): AppliedLogConditions {
+  return {
+    service: serviceName.value,
+    instanceId: instanceIdRef.value,
+    endpointId: endpointIdRef.value,
+    traceId: traceIdRef.value,
+    keywords: keywordsRef.value,
+    tags: allTags.value,
+    windowMinutes: windowMinutesEffective.value,
+    startTime: startTimeRef.value,
+    endTime: endTimeRef.value,
+  };
+}
+const applied = ref<AppliedLogConditions>(snapshotConditions());
+// Manual-fire gate: the log + facet queries stay disabled until the
+// operator presses Run query (or pages), so a freshly-opened tab shows a
+// "Run query" prompt rather than a misleading "no logs" empty state.
+const hasQueried = ref(false);
+function applyConditions(): void {
+  applied.value = snapshotConditions();
+}
+// A service switch is a context change → clear back to the Run-query
+// prompt (cascade-clear: never show the prior service's logs under the
+// new one). Filter edits just stage; they wait for Run query.
+watch(serviceName, () => {
+  hasQueried.value = false;
+  applyConditions();
+});
+const aService = computed(() => applied.value.service);
+const aInstanceId = computed(() => applied.value.instanceId);
+const aEndpointId = computed(() => applied.value.endpointId);
+const aTraceId = computed(() => applied.value.traceId);
+const aKeywords = computed(() => applied.value.keywords);
+const aTags = computed(() => applied.value.tags);
+const aWindowMinutes = computed(() => applied.value.windowMinutes);
+const aStartTime = computed(() => applied.value.startTime);
+const aEndTime = computed(() => applied.value.endTime);
+
 const { logs, total, isFetching, error, refetch } = useLayerLogs(layerKey, {
-  service: serviceName,
-  instanceId: instanceIdRef,
-  endpointId: endpointIdRef,
-  traceId: traceIdRef,
-  keywords: keywordsRef,
-  tags: allTags,
+  service: aService,
+  instanceId: aInstanceId,
+  endpointId: aEndpointId,
+  traceId: aTraceId,
+  keywords: aKeywords,
+  tags: aTags,
   page,
   pageSize,
-  windowMinutes: windowMinutesEffective,
-  startTime: startTimeRef,
-  endTime: endTimeRef,
+  windowMinutes: aWindowMinutes,
+  startTime: aStartTime,
+  endTime: aEndTime,
+  enabled: hasQueried,
 });
 
 const { facets } = useLayerLogFacets(layerKey, {
-  service: serviceName,
-  instanceId: instanceIdRef,
-  endpointId: endpointIdRef,
-  traceId: traceIdRef,
-  keywords: keywordsRef,
-  windowMinutes: windowMinutesEffective,
-  startTime: startTimeRef,
-  endTime: endTimeRef,
+  service: aService,
+  instanceId: aInstanceId,
+  endpointId: aEndpointId,
+  traceId: aTraceId,
+  keywords: aKeywords,
+  windowMinutes: aWindowMinutes,
+  startTime: aStartTime,
+  endTime: aEndTime,
+  enabled: hasQueried,
 });
 
 // Run-query handler mirrors the trace tab: refetch both the log
@@ -238,6 +296,8 @@ const { facets } = useLayerLogFacets(layerKey, {
 // now" affordance, identical voice to `LayerTracesView#runQuery`.
 function runQuery(): void {
   page.value = 1;
+  hasQueried.value = true;
+  applyConditions();
   void refetch();
 }
 
@@ -298,16 +358,35 @@ function onRowClick(r: LogRow): void {
   popoutRow.value = r;
 }
 
-/** Open the trace in the global popout overlay rather than navigating
- *  to the Traces tab — keeps the operator in the log stream, lets them
- *  scan the waterfall + close it back to where they were without
- *  losing the keyword filter / pagination state. The row's timestamp
- *  is passed as a hint so BanyanDB's `queryTrace` looks in the right
- *  window — without this, OAP searches only the last 1 day and any
- *  trace older than that (cold-tier, etc.) silently fails to load. */
+/** Drill from a log entry into its trace via the global trace popout. The
+ *  row's timestamp is passed so BanyanDB's `queryTrace` looks in the right
+ *  window — without it OAP only searches the last 1 day and older
+ *  (cold-tier) traces silently fail to load.
+ *
+ *  UX: one overlay at a time. Rather than stacking the trace popout on top
+ *  of the log popout, or just dropping the log popout (jarring), we
+ *  remember the entry, hide it, and reopen it when the trace popout closes
+ *  (Escape / × / back) — a drill-in / back flow. A bare row → trace jump
+ *  (no log popout open) leaves nothing to return to. */
+const logReturnRow = ref<LogRow | null>(null);
+watch([layerKey, serviceName], () => { logReturnRow.value = null; });
 function jumpToTrace(traceId: string, ts?: number): void {
+  if (popoutRow.value) {
+    logReturnRow.value = popoutRow.value;
+    popoutRow.value = null;
+  }
   openTrace(traceId, ts);
 }
+watch(
+  () => route.query[TRACE_POPOUT_QUERY],
+  (id, prev) => {
+    // Trace popout just closed → return to the log entry we drilled in from.
+    if (prev && !id && logReturnRow.value) {
+      popoutRow.value = logReturnRow.value;
+      logReturnRow.value = null;
+    }
+  },
+);
 </script>
 
 <template>
@@ -328,6 +407,7 @@ function jumpToTrace(traceId: string, ts?: number): void {
           <span>{{ logScope === 'instance' ? 'Sidecar' : 'Instance' }}</span>
           <select
             class="cf-input"
+            name="log-instance"
             :value="selectedInstance ?? ''"
             @change="setSelectedInstance(($event.target as 
HTMLSelectElement).value || null)"
           >
@@ -357,6 +437,8 @@ function jumpToTrace(traceId: string, ts?: number): void {
           <input
             v-model="traceIdInput"
             type="text"
+            name="log-trace-id"
+            autocomplete="off"
             class="cf-input mono"
             placeholder="paste trace id…"
           />
@@ -377,20 +459,20 @@ function jumpToTrace(traceId: string, ts?: number): void {
           <span>Time range</span>
           <template v-if="isCustomRange">
             <div class="cf-range">
-              <input v-model="customStart" type="datetime-local" 
class="cf-input cf-range-num" />
+              <input v-model="customStart" type="datetime-local" 
name="log-start" class="cf-input cf-range-num" />
               <span class="cf-range-sep">–</span>
-              <input v-model="customEnd" type="datetime-local" class="cf-input 
cf-range-num" />
+              <input v-model="customEnd" type="datetime-local" name="log-end" 
class="cf-input cf-range-num" />
               <button class="sw-btn small ghost" type="button" title="Back to 
presets" @click="windowMinutes = 30">×</button>
             </div>
           </template>
-          <select v-else v-model.number="windowMinutes" class="cf-input">
+          <select v-else v-model.number="windowMinutes" name="log-window" 
class="cf-input">
             <option v-for="p in TIME_RANGE_PRESETS" :key="p.minutes" 
:value="p.minutes">{{ p.label }}</option>
             <option :value="CUSTOM_RANGE_SENTINEL">Custom…</option>
           </select>
         </label>
         <label class="cf">
           <span>Page size</span>
-          <select v-model.number="pageSize" class="cf-input">
+          <select v-model.number="pageSize" name="log-page-size" 
class="cf-input">
             <option :value="20">20</option>
             <option :value="30">30</option>
             <option :value="50">50</option>
@@ -418,6 +500,13 @@ function jumpToTrace(traceId: string, ts?: number): void {
     <!-- Histogram + main stream -->
     <section class="lg-body sw-card">
       <div class="lg-main">
+        <!-- Manual-fire: until the operator runs a query, hide the whole
+             results stack and show the same "pick conditions" prompt the
+             Traces tab uses (see `hasQueried`). -->
+        <div v-if="!hasQueried" class="lg-empty">
+          Pick your conditions, then click Run query.
+        </div>
+        <template v-else>
         <!-- Top-of-table legend strip — one chip per level with the
              in-window count when data exists. Clickable: toggles the
              level filter. The service axis is intentionally absent
@@ -474,6 +563,7 @@ function jumpToTrace(traceId: string, ts?: number): void {
             >Next</button>
           </div>
         </div>
+        </template>
       </div>
     </section>
 
diff --git a/apps/ui/src/layer/logs/useLayerLogs.ts 
b/apps/ui/src/layer/logs/useLayerLogs.ts
index 4c03312..5cba8f7 100644
--- a/apps/ui/src/layer/logs/useLayerLogs.ts
+++ b/apps/ui/src/layer/logs/useLayerLogs.ts
@@ -17,7 +17,6 @@
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import { useAutoRefreshSubscribe } from 
'../../controls/useAutoRefreshSubscribe';
 import { bffClient } from '@/api/client';
 import type { LogFacetsResponse, LogsResponse, LogTagFilter } from 
'@/api/client';
 
@@ -33,6 +32,9 @@ export interface LogListParams {
   windowMinutes?: Ref<number>;
   startTime?: Ref<string | null>;
   endTime?: Ref<string | null>;
+  /** Gate the query — when false it never runs. Manual-fire pages hold it
+   *  until the operator presses Run query. Defaults to always-on. */
+  enabled?: Ref<boolean>;
 }
 
 export function useLayerLogs(layerKey: Ref<string>, params: LogListParams) {
@@ -67,10 +69,9 @@ export function useLayerLogs(layerKey: Ref<string>, params: 
LogListParams) {
         page: params.page.value,
         pageSize: params.pageSize.value,
       }),
-    enabled: computed(() => layerKey.value.length > 0),
+    enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true)),
     staleTime: 15_000,
   });
-  useAutoRefreshSubscribe(() => q.refetch());
 
   return {
     data: computed(() => q.data.value ?? null),
@@ -98,6 +99,7 @@ export interface LogFacetParams {
   windowMinutes?: Ref<number>;
   startTime?: Ref<string | null>;
   endTime?: Ref<string | null>;
+  enabled?: Ref<boolean>;
 }
 
 export function useLayerLogFacets(layerKey: Ref<string>, params: 
LogFacetParams) {
@@ -127,7 +129,7 @@ export function useLayerLogFacets(layerKey: Ref<string>, 
params: LogFacetParams)
           : {}),
         sampleSize: 200,
       }),
-    enabled: computed(() => layerKey.value.length > 0),
+    enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true)),
     staleTime: 30_000,
   });
   return {
diff --git a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue 
b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
index fe0b9fb..a6b0cba 100644
--- a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
+++ b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
@@ -23,6 +23,7 @@
 -->
 <script setup lang="ts">
 import { reactive, watch } from 'vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import type { EBPFTargetType } from '@/api/client';
 import type { NewEBPFTaskPayload } from '@/layer/profiling/useEBPFProfiling';
 
@@ -62,6 +63,11 @@ function close(): void {
   emit('update:show', false);
 }
 
+useEscapeToClose(
+  () => props.show,
+  close,
+);
+
 function submit(): void {
   const start =
     newTask.monitorTime === 'now' ? Date.now() : 
newTask.monitorTimeAt.getTime();
diff --git a/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue 
b/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
index a890f90..1b79741 100644
--- a/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
+++ b/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
@@ -24,6 +24,7 @@
 -->
 <script setup lang="ts">
 import { reactive, watch } from 'vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 
 export interface EndpointPick {
   id: string;
@@ -83,6 +84,11 @@ function close(): void {
   emit('update:show', false);
 }
 
+useEscapeToClose(
+  () => props.show,
+  close,
+);
+
 function submit(): void {
   const startTime =
     newTask.monitorTime === 'now' ? Date.now() : 
newTask.monitorTimeAt.getTime();
diff --git a/apps/ui/src/layer/profiling/TraceProfileTaskDetailModal.vue 
b/apps/ui/src/layer/profiling/TraceProfileTaskDetailModal.vue
index d512572..bb8f1be 100644
--- a/apps/ui/src/layer/profiling/TraceProfileTaskDetailModal.vue
+++ b/apps/ui/src/layer/profiling/TraceProfileTaskDetailModal.vue
@@ -23,8 +23,9 @@
 -->
 <script setup lang="ts">
 import type { ProfileTask, ProfileTaskLog } from '@/api/client';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 
-defineProps<{
+const props = defineProps<{
   task: ProfileTask | null;
   serviceName: string | null;
   logs: ProfileTaskLog[];
@@ -33,6 +34,11 @@ defineProps<{
 const emit = defineEmits<{
   (e: 'close'): void;
 }>();
+
+useEscapeToClose(
+  () => props.task != null,
+  () => emit('close'),
+);
 </script>
 
 <template>
diff --git a/apps/ui/src/layer/service-map/DeploymentEdgePanel.vue 
b/apps/ui/src/layer/service-map/DeploymentEdgePanel.vue
index e95583a..07749f5 100644
--- a/apps/ui/src/layer/service-map/DeploymentEdgePanel.vue
+++ b/apps/ui/src/layer/service-map/DeploymentEdgePanel.vue
@@ -25,6 +25,7 @@ import { ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import type { DeploymentCall, DeploymentMetricDef, DeploymentNode } from 
'@/api/client';
 import Sparkline from '@/components/charts/Sparkline.vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 
 export interface EdgeRow { id: string; label: string; unit: string; serverDef: 
DeploymentMetricDef | null; clientDef: DeploymentMetricDef | null }
 type EdgeRowKind = 'both' | 'client-only' | 'server-only' | 'none';
@@ -42,6 +43,8 @@ const props = defineProps<{
 const emit = defineEmits<{ (e: 'close'): void }>();
 const { t } = useI18n({ useScope: 'global' });
 
+useEscapeToClose(() => true, () => emit('close'));
+
 const hoveredEdgeRowId = ref<string | null>(null);
 const hoveredEdgeBucket = ref<number | null>(null);
 function onEdgeBucketHover(rowId: string, bucket: number): void { 
hoveredEdgeRowId.value = rowId; hoveredEdgeBucket.value = bucket; }
diff --git a/apps/ui/src/layer/service-map/DeploymentNodePopover.vue 
b/apps/ui/src/layer/service-map/DeploymentNodePopover.vue
index 0e4c2de..21f6164 100644
--- a/apps/ui/src/layer/service-map/DeploymentNodePopover.vue
+++ b/apps/ui/src/layer/service-map/DeploymentNodePopover.vue
@@ -22,6 +22,7 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import type { DeploymentNode, DeploymentMetricDef } from '@/api/client';
 
 const props = defineProps<{
@@ -34,6 +35,8 @@ const props = defineProps<{
 const emit = defineEmits<{ (e: 'close'): void; (e: 'open-dashboard', n: 
DeploymentNode): void }>();
 const { t } = useI18n({ useScope: 'global' });
 
+useEscapeToClose(() => true, () => emit('close'));
+
 // Attribute search — the FODC proxy stamps many labels onto each instance
 // (k8s_*, net_*, …), so the list is long; filter + scroll it. Reset on node 
swap.
 const attrFilter = ref('');
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue 
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index d543487..6ad0e5d 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -52,6 +52,7 @@
 -->
 <script setup lang="ts">
 import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router';
 import type {
   LayerDef,
@@ -560,6 +561,14 @@ function selectCall(id: string | null): void {
   if (embedded.value) return;
   selectedCallId.value = selectedCallId.value === id ? null : id;
 }
+// Escape closes whichever detail panel (node or edge) is open.
+useEscapeToClose(
+  () => Boolean(selectedNodeId.value || selectedCallId.value),
+  () => {
+    selectedNodeId.value = null;
+    selectedCallId.value = null;
+  },
+);
 const selectedNode = computed<LayoutNode | null>(() => {
   const id = selectedNodeId.value;
   if (!id) return null;
diff --git a/apps/ui/src/layer/service-map/TopologyDetailPanels.vue 
b/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
index 9a5823d..c993348 100644
--- a/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
+++ b/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
@@ -589,7 +589,10 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
   flex: 0 0 auto;
 }
 .sp-edge-row {
-  display: inline-flex;
+  /* Block-level (not inline-flex) so it fills `.sp-id`'s column and wraps
+     WITHIN it — inline-flex shrink-to-fit let long source→target names
+     overflow rightward under the header action buttons. */
+  display: flex;
   align-items: center;
   gap: 6px;
   flex-wrap: wrap;
diff --git a/apps/ui/src/render/widgets/LogDetailPopout.vue 
b/apps/ui/src/render/widgets/LogDetailPopout.vue
index 7a0a669..bdbe7ce 100644
--- a/apps/ui/src/render/widgets/LogDetailPopout.vue
+++ b/apps/ui/src/render/widgets/LogDetailPopout.vue
@@ -32,7 +32,7 @@
     jump-trace — { traceId, ts } when the operator clicks the trace link.
 -->
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import type { LogRow } from '@/api/client';
 import Modal from '@/features/operate/_shared/Modal.vue';
@@ -81,14 +81,26 @@ function fmtDate(ts: number): string {
 
 const fmt = computed<LogFormat | null>(() => (props.row ? 
detectFormat(props.row) : null));
 
+// Brief "Copied" confirmation on the button after a successful copy.
+const copied = ref(false);
+let copyTimer: ReturnType<typeof setTimeout> | null = null;
 async function copyContent(): Promise<void> {
   if (!props.row) return;
   try {
     await navigator.clipboard.writeText(props.row.content);
+    copied.value = true;
+    if (copyTimer) clearTimeout(copyTimer);
+    copyTimer = setTimeout(() => {
+      copied.value = false;
+      copyTimer = null;
+    }, 1500);
   } catch {
     /* clipboard may be blocked; silently no-op */
   }
 }
+// Reset the confirmation when the popout switches to another row.
+watch(() => props.row, () => { copied.value = false; });
+onBeforeUnmount(() => { if (copyTimer) clearTimeout(copyTimer); });
 function onJumpTrace(): void {
   if (props.row?.traceId) emit('jump-trace', { traceId: props.row.traceId, ts: 
props.row.timestamp });
 }
@@ -103,7 +115,7 @@ function onJumpTrace(): void {
           <span class="ld-fmt">{{ fmt!.toUpperCase() }}</span>
         </div>
         <div class="ld-ctrls">
-          <button class="sw-btn small" type="button" @click="copyContent">{{ 
t('Copy') }}</button>
+          <button class="sw-btn small" :class="{ 'is-copied': copied }" 
type="button" @click="copyContent">{{ copied ? t('Copied') : t('Copy') 
}}</button>
           <button v-if="row.traceId" class="sw-btn small" type="button" 
@click="onJumpTrace">↗ {{ t('trace') }}</button>
         </div>
       </div>
@@ -178,6 +190,7 @@ function onJumpTrace(): void {
   color: var(--sw-fg-1); border-radius: 4px; font: inherit; font-size: 11px; 
cursor: pointer;
 }
 .sw-btn.small:hover { color: var(--sw-fg-0); border-color: var(--sw-fg-3); }
+.sw-btn.small.is-copied { color: var(--sw-accent-2); background: 
var(--sw-accent-soft); border-color: var(--sw-accent-2); }
 .mono { font-family: var(--sw-mono); }
 @media (max-width: 900px) {
   .ld-split { flex-direction: column; }
diff --git a/apps/ui/src/render/widgets/SpanDetailModal.vue 
b/apps/ui/src/render/widgets/SpanDetailModal.vue
index 7aa93f9..278b4e2 100644
--- a/apps/ui/src/render/widgets/SpanDetailModal.vue
+++ b/apps/ui/src/render/widgets/SpanDetailModal.vue
@@ -34,6 +34,7 @@
 import { useI18n } from 'vue-i18n';
 import type { NativeSpan, TraceAttachedEvent, TraceLogEntry } from 
'@/api/client';
 import { useTracePopout } from '@/layer/traces/useTracePopout';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import {
   serviceColorFrom, kindColor, fmtMs, fmtDateTime, fmtAttachedTs,
 } from './traceDetailShared';
@@ -45,10 +46,12 @@ const props = defineProps<{
   traceId: string | null;
   serviceColors: Map<string, string>;
 }>();
-defineEmits<{ (e: 'close'): void }>();
+const emit = defineEmits<{ (e: 'close'): void }>();
 
 const { openTrace } = useTracePopout();
 
+useEscapeToClose(() => true, () => emit('close'));
+
 function serviceColor(c: string): string { return 
serviceColorFrom(props.serviceColors, c); }
 function nativeSpanError(s: NativeSpan): boolean { return s.isError; }
 </script>
diff --git a/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue 
b/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue
index b11b8a1..3e89be2 100644
--- a/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue
+++ b/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue
@@ -35,6 +35,7 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
 import type { ZipkinSpan } from '@/api/client';
 
 const { t } = useI18n({ useScope: 'global' });
@@ -200,6 +201,7 @@ function selectSpan(s: ZipkinSpan): void {
 function clearSpan(): void { selectedSpanId.value = null; }
 watch(() => props.traceId, () => { selectedSpanId.value = null; });
 watch(selectedSpan, (s) => emit('update:modalOpen', !!s));
+useEscapeToClose(() => selectedSpanId.value !== null, closeSpanModal);
 </script>
 
 <template>


Reply via email to