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>
