This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/explore in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit c0a71fa7f2db2bfe5c78506fff4290d481f6beee Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 23 17:21:18 2026 +0800 fix(explore): form-left/distribution-right square layout, Esc close, Traces header - Form (Target + conditions) on the left, the distribution scatter a square on the right; Run query + resolved toggle move to the top-right of the conditions, under a "Traces" header. - Fix Esc on Trace inspect: it had no key handler, so the span-detail modal couldn't be dismissed. Mirror the per-layer cascade (Esc closes the span modal, else the inline detail) via the card's modal-open report + a capture-phase keydown listener. - Strip the explanatory comments the recent layout passes added. --- .../src/features/operate/explore/ExploreView.vue | 286 +++++++++++---------- apps/ui/src/layer/traces/LayerTracesView.vue | 17 +- 2 files changed, 146 insertions(+), 157 deletions(-) diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue index 15e987b..4421254 100644 --- a/apps/ui/src/features/operate/explore/ExploreView.vue +++ b/apps/ui/src/features/operate/explore/ExploreView.vue @@ -29,7 +29,7 @@ (raw / browser-error) land next on the same spine. --> <script setup lang="ts"> -import { computed, reactive, ref, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { bffClient } from '@/api/client'; import type { @@ -333,6 +333,23 @@ function changeSelectedTraceId(id: string): void { selectedTraceId.value = id; embeddedSpans.value = null; } + +const detailCard = ref<InstanceType<typeof TraceDetailCard> | null>(null); +const spanModalOpen = ref<boolean>(false); +function onPageKeyDown(e: KeyboardEvent): void { + if (e.key !== 'Escape') return; + if (spanModalOpen.value) { + detailCard.value?.closeSpanModal(); + e.preventDefault(); + e.stopPropagation(); + } else if (selectedTraceId.value) { + closeDetail(); + e.preventDefault(); + e.stopPropagation(); + } +} +onMounted(() => window.addEventListener('keydown', onPageKeyDown, true)); +onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true)); </script> <template> @@ -353,134 +370,131 @@ function changeSelectedTraceId(id: string): void { </div> </div> - <div class="iq-target"> - <div class="iq-target-h"> - <span>{{ t('Target') }} <small class="dim">{{ t('optional — blank queries all services') }}</small></span> - <div class="seg sm"> - <button :class="{ on: entityMode === 'pick' }" @click="entityMode = 'pick'">{{ t('Pick') }}</button> - <button :class="{ on: entityMode === 'type' }" @click="entityMode = 'type'">{{ t('Type') }}</button> + <div class="iq-top-strip"> + <div class="iq-form"> + <div class="iq-target"> + <div class="iq-target-h"> + <span>{{ t('Target') }} <small class="dim">{{ t('optional — blank queries all services') }}</small></span> + <div class="seg sm"> + <button :class="{ on: entityMode === 'pick' }" @click="entityMode = 'pick'">{{ t('Pick') }}</button> + <button :class="{ on: entityMode === 'type' }" @click="entityMode = 'type'">{{ t('Type') }}</button> + </div> + <button v-if="entityMode === 'pick'" class="iq-link" :disabled="!pickServiceId" @click="seedTypeFromPick"> + {{ t('→ edit as text') }} + </button> + </div> + + <div class="iq-grid" v-if="entityMode === 'pick'"> + <label class="cf"> + <span>{{ t('Layer') }}</span> + <TypeaheadSelect v-model="pickLayer" :aria-label="t('Layer')" :options="layerOptions" :placeholder="t('Any layer')" class="cf-tas" /> + </label> + <label class="cf"> + <span>{{ t('Service') }}</span> + <TypeaheadSelect + v-model="pickServiceId" :aria-label="t('Service')" :options="serviceOptions" :disabled="!pickLayer || servicesLoading" + :placeholder="servicesLoading ? t('Reading…') : t('Any service')" class="cf-tas" + /> + </label> + <label class="cf"> + <span>{{ t('Instance') }}</span> + <TypeaheadSelect v-model="instanceSel" :aria-label="t('Instance')" :options="instanceOptions" :disabled="!pickServiceId" :placeholder="t('All instances')" class="cf-tas" /> + </label> + <label class="cf"> + <span>{{ t('Endpoint') }}</span> + <TypeaheadSelect v-model="endpointSel" :aria-label="t('Endpoint')" :options="endpointOptions" :disabled="!pickServiceId" :placeholder="t('All endpoints')" class="cf-tas" /> + </label> + </div> + + <div class="iq-grid" v-else> + <label class="cf"> + <span>{{ t('Service name') }}</span> + <input v-model="typeService" class="cf-input mono" type="text" :placeholder="t('e.g. agent::checkout')" /> + </label> + <label class="cf cf-chk"> + <span>{{ t('Real') }}</span> + <span class="iq-chk"><input v-model="typeReal" type="checkbox" /> <small class="dim">{{ t('off = virtual / peer') }}</small></span> + </label> + <label class="cf"> + <span>{{ t('Instance') }}</span> + <input v-model="typeInstance" class="cf-input" type="text" :placeholder="t('optional')" /> + </label> + <label class="cf"> + <span>{{ t('Endpoint') }}</span> + <input v-model="typeEndpoint" class="cf-input" type="text" :placeholder="t('optional')" /> + </label> + </div> </div> - <button v-if="entityMode === 'pick'" class="iq-link" :disabled="!pickServiceId" @click="seedTypeFromPick"> - {{ t('→ edit as text') }} - </button> - </div> - - <div class="iq-grid" v-if="entityMode === 'pick'"> - <label class="cf"> - <span>{{ t('Layer') }}</span> - <TypeaheadSelect v-model="pickLayer" :aria-label="t('Layer')" :options="layerOptions" :placeholder="t('Any layer')" class="cf-tas" /> - </label> - <label class="cf"> - <span>{{ t('Service') }}</span> - <TypeaheadSelect - v-model="pickServiceId" :aria-label="t('Service')" :options="serviceOptions" :disabled="!pickLayer || servicesLoading" - :placeholder="servicesLoading ? t('Reading…') : t('Any service')" class="cf-tas" - /> - </label> - <label class="cf"> - <span>{{ t('Instance') }}</span> - <TypeaheadSelect v-model="instanceSel" :aria-label="t('Instance')" :options="instanceOptions" :disabled="!pickServiceId" :placeholder="t('All instances')" class="cf-tas" /> - </label> - <label class="cf"> - <span>{{ t('Endpoint') }}</span> - <TypeaheadSelect v-model="endpointSel" :aria-label="t('Endpoint')" :options="endpointOptions" :disabled="!pickServiceId" :placeholder="t('All endpoints')" class="cf-tas" /> - </label> - </div> - - <div class="iq-grid" v-else> - <label class="cf"> - <span>{{ t('Service name') }}</span> - <input v-model="typeService" class="cf-input mono" type="text" :placeholder="t('e.g. agent::checkout')" /> - </label> - <label class="cf cf-chk"> - <span>{{ t('Real') }}</span> - <span class="iq-chk"><input v-model="typeReal" type="checkbox" /> <small class="dim">{{ t('off = virtual / peer') }}</small></span> - </label> - <label class="cf"> - <span>{{ t('Instance') }}</span> - <input v-model="typeInstance" class="cf-input" type="text" :placeholder="t('optional')" /> - </label> - <label class="cf"> - <span>{{ t('Endpoint') }}</span> - <input v-model="typeEndpoint" class="cf-input" type="text" :placeholder="t('optional')" /> - </label> - </div> - </div> - <!-- Top strip: trace conditions (left) + duration distribution - (right), mirroring the per-layer Traces tab's `.tr-top-strip` - 4fr/1fr split. --> - <div class="iq-top-strip"> - <div class="iq-conditions"> - <div class="iq-grid"> - <label class="cf"> - <span>{{ t('Trace ID') }}</span> - <input v-model="cond.traceId" class="cf-input mono" type="text" :placeholder="t('paste a trace id')" /> - </label> - <label class="cf"> - <span>{{ t('Status') }}</span> - <select v-model="cond.traceState" class="cf-input"> - <option value="ALL">{{ t('All') }}</option> - <option value="SUCCESS">{{ t('Success') }}</option> - <option value="ERROR">{{ t('Error') }}</option> - </select> - </label> - <label class="cf"> - <span>{{ t('Order') }}</span> - <select v-model="cond.queryOrder" class="cf-input"> - <option value="BY_START_TIME">{{ t('Newest') }}</option> - <option value="BY_DURATION">{{ t('Slowest') }}</option> - </select> - </label> - <label class="cf"> - <span>{{ t('Duration (ms)') }}</span> - <span class="cf-range"> - <input v-model="cond.minDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('min')" /> - <span class="cf-range-sep">–</span> - <input v-model="cond.maxDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('max')" /> - </span> - </label> - <label class="cf cf-wide"> - <span>{{ t('Tags') }}</span> - <input v-model="cond.tags" class="cf-input mono" type="text" :placeholder="t('http.status_code=500, …')" /> - </label> - <label class="cf" :class="{ 'cf-wide': isCustomRange }"> - <span>{{ t('Time') }}</span> - <template v-if="isCustomRange"> + <div class="iq-conditions"> + <div class="iq-conditions-h"> + <span class="kicker iq-cond-kicker">{{ t('Traces') }}</span> + <button v-if="resolved" class="iq-resolved-tog" @click="showResolved = !showResolved"> + {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} + <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` · ${resolved.backend}` : '' }}</span> + </button> + <button class="iq-run" :disabled="!canRun || running" @click="runQuery"> + {{ running ? t('Running…') : t('Run query') }} + </button> + </div> + <div class="iq-grid"> + <label class="cf"> + <span>{{ t('Trace ID') }}</span> + <input v-model="cond.traceId" class="cf-input mono" type="text" :placeholder="t('paste a trace id')" /> + </label> + <label class="cf"> + <span>{{ t('Status') }}</span> + <select v-model="cond.traceState" class="cf-input"> + <option value="ALL">{{ t('All') }}</option> + <option value="SUCCESS">{{ t('Success') }}</option> + <option value="ERROR">{{ t('Error') }}</option> + </select> + </label> + <label class="cf"> + <span>{{ t('Order') }}</span> + <select v-model="cond.queryOrder" class="cf-input"> + <option value="BY_START_TIME">{{ t('Newest') }}</option> + <option value="BY_DURATION">{{ t('Slowest') }}</option> + </select> + </label> + <label class="cf"> + <span>{{ t('Duration (ms)') }}</span> <span class="cf-range"> - <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" /> + <input v-model="cond.minDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('min')" /> <span class="cf-range-sep">–</span> - <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" /> - <button class="iq-range-reset" type="button" :title="t('Back to presets')" @click="cond.windowMinutes = 30">×</button> + <input v-model="cond.maxDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('max')" /> </span> - </template> - <select v-else v-model.number="cond.windowMinutes" class="cf-input"> - <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? `${w}m` : `${w / 60}h` }}</option> - <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option> - </select> - </label> - <label class="cf"> - <span>{{ t('Limit') }}</span> - <select v-model.number="cond.limit" class="cf-input"> - <option v-for="l in LIMITS" :key="l" :value="l">{{ l }}</option> - </select> - </label> - </div> - - <div class="iq-run-row"> - <button class="iq-run" :disabled="!canRun || running" @click="runQuery"> - {{ running ? t('Running…') : t('Run query') }} - </button> - <button v-if="resolved" class="iq-resolved-tog" @click="showResolved = !showResolved"> - {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} - <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` · ${resolved.backend}` : '' }}</span> - </button> + </label> + <label class="cf cf-wide"> + <span>{{ t('Tags') }}</span> + <input v-model="cond.tags" class="cf-input mono" type="text" :placeholder="t('http.status_code=500, …')" /> + </label> + <label class="cf" :class="{ 'cf-wide': isCustomRange }"> + <span>{{ t('Time') }}</span> + <template v-if="isCustomRange"> + <span class="cf-range"> + <input v-model="customStart" type="datetime-local" 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" /> + <button class="iq-range-reset" type="button" :title="t('Back to presets')" @click="cond.windowMinutes = 30">×</button> + </span> + </template> + <select v-else v-model.number="cond.windowMinutes" class="cf-input"> + <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? `${w}m` : `${w / 60}h` }}</option> + <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option> + </select> + </label> + <label class="cf"> + <span>{{ t('Limit') }}</span> + <select v-model.number="cond.limit" class="cf-input"> + <option v-for="l in LIMITS" :key="l" :value="l">{{ l }}</option> + </select> + </label> + </div> + <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> </div> - <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> </div> - <!-- Duration distribution — same dot-plot as the per-layer Traces - tab. Click a dot to open that trace. --> <section class="iq-scatter sw-card"> <header class="iq-scatter-head"> <span class="kicker">{{ t('Distribution') }}</span> @@ -498,8 +512,6 @@ function changeSelectedTraceId(id: string): void { </section> </div> - <!-- Persists across browse + detail so the active trace-query API - (and what a row represents) stays visible after a click. --> <div v-if="showApiBanner" class="iq-api-banner"> {{ t('This OAP serves traces via') }} <b>{{ t('Trace Query {label} API', { label: traceApiLabel }) }}</b> (<code>{{ native?.api }}</code>). @@ -516,7 +528,6 @@ function changeSelectedTraceId(id: string): void { <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div> <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No traces in this window.') }}</div> - <!-- Browsing mode: full-width list when no trace is selected. --> <article v-else-if="!selectedTraceId" class="iq-list-card sw-card"> <header class="iq-list-head"> <h4>{{ isSegmentList ? t('Segments') : t('Traces') }}</h4> @@ -530,7 +541,6 @@ function changeSelectedTraceId(id: string): void { /> </article> - <!-- Inline detail: folded rail (left) + detail (right). --> <section v-else class="iq-detail-split" :class="{ 'rail-collapsed': !railOpen }"> <TraceListPanel foldable @@ -548,12 +558,14 @@ function changeSelectedTraceId(id: string): void { <div v-else-if="waterfallSpans.length === 0" class="iq-empty sm">{{ t('No spans (older than the detail window).') }}</div> <TraceDetailCard v-else + ref="detailCard" :spans="waterfallSpans" :trace-id="selectedTraceId" :trace-ids="selectedTraceIds" :loading="detailFetching" @close="closeDetail" @change-trace-id="changeSelectedTraceId" + @update:modal-open="spanModalOpen = $event" /> </div> </section> @@ -606,23 +618,21 @@ function changeSelectedTraceId(id: string): void { .cf-range { display: flex; align-items: center; gap: 4px; } .cf-range-num { flex: 1; min-width: 0; } .cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; } -.iq-run-row { display: flex; align-items: center; gap: 12px; } -.iq-run { background: var(--sw-accent); color: #fff; border: none; border-radius: 4px; padding: 5px 18px; font: inherit; font-size: 12px; cursor: pointer; height: 28px; } +.iq-conditions-h { display: flex; align-items: center; gap: 12px; } +.iq-cond-kicker { margin-right: auto; } +.iq-run { background: var(--sw-accent); color: #fff; border: none; border-radius: 4px; padding: 5px 18px; font: inherit; font-size: 12px; cursor: pointer; height: 28px; order: 2; } .iq-run:disabled { opacity: 0.5; cursor: not-allowed; } .iq-resolved-tog { background: none; border: none; color: var(--sw-fg-3); font-size: 11px; cursor: pointer; } .iq-resolved-tog .dim { color: var(--sw-fg-4); margin-left: 6px; } .iq-resolved-body { margin: 0; padding: 8px 12px; font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-2); background: var(--sw-bg-0); overflow: auto; max-height: 160px; border: 1px solid var(--sw-line); border-radius: 5px; } -/* Top strip — conditions (left) + duration distribution (right), - mirroring the per-layer Traces tab's `.tr-top-strip` 4fr/1fr split. */ -.iq-top-strip { display: grid; grid-template-columns: 4fr 1fr; gap: 12px; align-items: stretch; } +.iq-top-strip { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: stretch; } @media (max-width: 1100px) { .iq-top-strip { grid-template-columns: 1fr; } } +.iq-form { display: flex; flex-direction: column; gap: 10px; min-width: 0; } .iq-conditions { display: flex; flex-direction: column; gap: 10px; min-width: 0; } -/* Distribution card — mirrors the per-layer Traces tab's scatter card: - a tight kicker + ok/err legend head, the shared dot-plot filling the - rest of the card height. */ -.iq-scatter { padding: 6px 10px 8px; display: flex; flex-direction: column; min-height: 0; margin: 0; } +.iq-scatter { padding: 6px 10px 8px; display: flex; flex-direction: column; min-height: 0; margin: 0; height: 100%; aspect-ratio: 1; } +@media (max-width: 1100px) { .iq-scatter { aspect-ratio: auto; height: 320px; } } .iq-scatter-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 2px; flex: 0 0 auto; } .iq-scatter-head .legend { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); display: inline-flex; gap: 10px; align-items: center; } .iq-scatter-head .lg { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; } @@ -650,16 +660,10 @@ function changeSelectedTraceId(id: string): void { .iq-empty, .iq-err { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 12px; } .iq-empty.sm { padding: 14px; } .iq-err { color: var(--sw-err); } -/* List pane — the same sw-card + header the per-layer Traces tab uses, - with the row list scrolling inside. Full width when no trace is - selected. */ -.iq-list-card { padding: 0; display: flex; flex-direction: column; min-height: 0; } +.iq-list-card { padding: 0; display: flex; flex-direction: column; min-height: 0; max-height: calc(100vh - 80px); overflow: hidden; } .iq-list-head { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; } .iq-list-head h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); } .iq-list-head .hint { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); } -/* Inline detail split — folded rail (left) + detail (right). Height is - driven by the detail card's content; the rail sticks to match. Mirrors - the per-layer Traces tab's `.tr-detail-split`. */ .iq-detail-split { display: grid; grid-template-columns: 320px 1fr; gap: 12px; align-items: start; } .iq-detail-split.rail-collapsed { grid-template-columns: 64px 1fr; } .iq-detail { overflow: auto; min-width: 0; } diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue b/apps/ui/src/layer/traces/LayerTracesView.vue index 7b7a5c8..69cce84 100644 --- a/apps/ui/src/layer/traces/LayerTracesView.vue +++ b/apps/ui/src/layer/traces/LayerTracesView.vue @@ -417,7 +417,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) <template> <div class="tr-tab"> - <!-- Top strip: filter (80%) + duration distribution (20%). --> <div class="tr-top-strip"> <header class="tr-toolbar sw-card"> <div class="tr-toolbar-head"> @@ -532,9 +531,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) </div> </header> - <!-- Distribution: dots only. Y axis dropped — the y value is - surfaced on hover (tooltip) and re-surfaced in the list-row - bar below. Click a dot to select the matching trace. --> <section class="tr-scatter sw-card"> <header class="tr-scatter-head"> <!-- Header text swaps when the operator is picking dots: @@ -566,8 +562,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) </section> </div> - <!-- Persists across browse + detail so the active trace-query API - (and what a row represents) stays visible after a click. --> <div v-if="showApiBanner" class="tr-api-banner"> {{ t('This OAP serves traces via') }} <b>{{ t('Trace Query {label} API', { label: traceApiLabel }) }}</b> (<code>{{ native?.api }}</code>). @@ -579,7 +573,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) </template> </div> - <!-- Browsing mode: full-width list when no trace is selected --> <template v-if="!selectedTraceId"> <article class="tr-list-card sw-card"> <header class="tr-list-head"> @@ -607,7 +600,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) </template> - <!-- Inline detail: folded rail (left) + detail (right) --> <section v-else class="tr-detail-split" :class="{ 'rail-collapsed': !railOpen }"> <TraceListPanel foldable @@ -746,8 +738,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) .sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; } .sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2); color: var(--sw-fg-2); } -/* Scatter — fills as much of the card as possible. Kicker + legend - sit on one tight strip; the chart claims the rest. */ .tr-scatter { padding: 6px 10px 8px; display: flex; @@ -776,8 +766,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) shared component fills the remaining card height via its own flex. */ .tr-scatter :deep(.scatter-wrap) { flex: 1; min-height: 0; } -/* Browsing list */ -.tr-list-card { padding: 0; display: flex; flex-direction: column; } +.tr-list-card { padding: 0; display: flex; flex-direction: column; min-height: 0; max-height: calc(100vh - 80px); overflow: hidden; } .tr-list-head { display: flex; align-items: baseline; @@ -815,10 +804,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) } .tr-api-banner b { color: var(--sw-fg-0); } .tr-empty { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; } -/* Inline detail split — height is driven by the *content* of the - detail card on the right. The rail on the left stretches to match - (sticky to its scroll context). No fixed min/max-height: short - traces stay compact, long ones make the page scroll. */ .tr-detail-split { display: grid; grid-template-columns: 320px 1fr;
