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 e9d63a21ef08e0ef99e900d84988dc7ea88dfc13 Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 23 16:45:32 2026 +0800 fix(explore): Trace inspect layout matches the Traces tab Mirror the per-layer Traces result-body composition: a top-strip with the conditions on the left (max 3 columns) and the distribution scatter on the right; the v1/v2 Trace-Query-API banner; and a two-state result — a FULL-WIDTH browsing list when nothing is selected, switching to the rail + detail split (with rail fold) only on selection. ExploreView-only; the shared widgets and the Traces tab are untouched. --- .../src/features/operate/explore/ExploreView.vue | 281 +++++++++++++-------- 1 file changed, 175 insertions(+), 106 deletions(-) diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue index 8cdfb54..15e987b 100644 --- a/apps/ui/src/features/operate/explore/ExploreView.vue +++ b/apps/ui/src/features/operate/explore/ExploreView.vue @@ -299,11 +299,20 @@ async function runQuery(): Promise<void> { const rows = computed<NativeTraceListRow[]>(() => (hasQueried.value ? native.value?.traces ?? [] : [])); const maxDuration = computed(() => (rows.value.length === 0 ? 0 : Math.max(...rows.value.map((r) => r.duration)))); +// Which OAP query answered. `queryBasicTraces` (Trace Query v1 API) +// returns trace SEGMENTS; `queryTraces` (v2, BanyanDB only) returns whole +// traces with spans inline. The banner states the API so operators always +// know what a row represents — same wording as the per-layer Traces tab. +const isSegmentList = computed(() => native.value?.api === 'queryBasicTraces'); +const traceApiLabel = computed(() => (native.value?.api === 'queryTraces' ? 'v2' : 'v1')); +const showApiBanner = computed(() => hasQueried.value && !!native.value?.reachable); + // ── detail (reuses the shared trace-detail card + GET /api/trace/:id) ─ const selectedTraceId = ref<string | null>(null); const selectedTraceIds = ref<string[]>([]); const selectedRowKey = ref<string | null>(null); const embeddedSpans = ref<NativeSpan[] | null>(null); +const railOpen = ref<boolean>(true); const sourceRef = ref<'native' | 'zipkin'>('native'); const { nativeDetail, isFetching: detailFetching } = useTraceDetail(selectedTraceId, sourceRef); const waterfallSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? nativeDetail.value?.spans ?? []); @@ -398,110 +407,144 @@ function changeSelectedTraceId(id: string): void { </div> </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="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"> - <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> + <!-- 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"> + <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> + + <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> + </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> + <span class="legend"> + <span class="lg ok" /> {{ t('ok') }} + <span class="lg err" /> {{ t('err') }} </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> + </header> + <TraceDistribution + :rows="rows" + :max-duration="maxDuration" + :selected-key="selectedRowKey" + @select="openRow" + /> + </section> </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> + <!-- 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>). + <template v-if="isSegmentList"> + {{ t('Each row is a trace') }} <b>{{ t('segment') }}</b> — {{ t('click one to fetch its full trace.') }} + </template> + <template v-else> + {{ t('Full traces are returned inline.') }} + </template> </div> - <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> - - <!-- Duration distribution — same dot-plot as the per-layer Traces - tab. Click a dot to open that trace. --> - <section v-if="hasQueried && !errorMsg && rows.length > 0" class="iq-scatter sw-card"> - <header class="iq-scatter-head"> - <span class="kicker">{{ t('Distribution') }}</span> - <span class="legend"> - <span class="lg ok" /> {{ t('ok') }} - <span class="lg err" /> {{ t('err') }} - </span> - </header> - <TraceDistribution - :rows="rows" - :max-duration="maxDuration" - :selected-key="selectedRowKey" - @select="openRow" - /> - </section> <div class="iq-result"> <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a service or leave it blank.') }}</div> <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> - <div v-else class="iq-split"> - <article class="iq-list-card sw-card"> - <header class="iq-list-head"> - <h4>{{ t('Traces') }}</h4> - <span class="hint">{{ t('{n} traces', { n: rows.length }) }}</span> - </header> - <TraceListPanel - :rows="rows" - :selected-key="selectedRowKey" - :max-duration="maxDuration" - @select="openRow" - /> - </article> + + <!-- 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> + <span class="hint">{{ rows.length }} {{ isSegmentList ? t('segments') : t('traces') }}</span> + </header> + <TraceListPanel + :rows="rows" + :selected-key="selectedRowKey" + :max-duration="maxDuration" + @select="openRow" + /> + </article> + + <!-- Inline detail: folded rail (left) + detail (right). --> + <section v-else class="iq-detail-split" :class="{ 'rail-collapsed': !railOpen }"> + <TraceListPanel + foldable + :rail-open="railOpen" + :rows="rows" + :selected-key="selectedRowKey" + :max-duration="maxDuration" + :title="isSegmentList ? t('Segments') : t('Traces')" + :count-hint="rows.length" + @select="openRow" + @toggle-rail="railOpen = !railOpen" + /> <div class="iq-detail"> - <div v-if="!selectedTraceId" class="iq-empty sm">{{ t('Select a trace.') }}</div> - <div v-else-if="detailFetching && waterfallSpans.length === 0" class="iq-empty sm">{{ t('Reading trace…') }}</div> + <div v-if="detailFetching && waterfallSpans.length === 0" class="iq-empty sm">{{ t('Reading trace…') }}</div> <div v-else-if="waterfallSpans.length === 0" class="iq-empty sm">{{ t('No spans (older than the detail window).') }}</div> <TraceDetailCard v-else @@ -513,7 +556,7 @@ function changeSelectedTraceId(id: string): void { @change-trace-id="changeSelectedTraceId" /> </div> - </div> + </section> </div> </template> </div> @@ -543,8 +586,9 @@ function changeSelectedTraceId(id: string): void { .iq-target-h .dim { color: var(--sw-fg-4); font-weight: 400; } .iq-link { background: none; border: none; color: var(--sw-accent); font-size: 11px; cursor: pointer; padding: 0; margin-left: auto; } .iq-link:disabled { color: var(--sw-fg-4); cursor: not-allowed; } -.iq-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 10px; } +.iq-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 10px; } @media (max-width: 900px) { .iq-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 560px) { .iq-grid { grid-template-columns: 1fr; } } .cf { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--sw-fg-3); font-weight: 500; min-width: 0; } .cf.cf-wide { grid-column: span 2; } .cf.cf-chk { justify-content: flex-end; } @@ -568,31 +612,56 @@ function changeSelectedTraceId(id: string): void { .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; } -/* Distribution card — mirrors the per-layer Traces tab's scatter strip: - a tight kicker + ok/err legend head, the shared dot-plot below. */ -.iq-scatter { padding: 6px 10px 8px; display: flex; flex-direction: column; flex: 0 0 auto; } + +/* 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; } +@media (max-width: 1100px) { .iq-top-strip { grid-template-columns: 1fr; } } +.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-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; } .iq-scatter-head .lg.ok { background: var(--sw-accent); } .iq-scatter-head .lg.err { background: var(--sw-err); } .kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--sw-accent); font-weight: 600; } -/* The dot-plot lives in TraceDistribution.vue; cap its height so the - distribution stays a strip and the list/detail below get the rest. */ -.iq-scatter :deep(.scatter-wrap) { flex: 0 0 auto; } -.iq-scatter :deep(.scatter-svg) { min-height: 120px; max-height: 160px; } +/* The scatter chart internals live in TraceDistribution.vue; this card + only styles the head strip. The shared component fills the remaining + card height via its own flex. */ +.iq-scatter :deep(.scatter-wrap) { flex: 1; min-height: 0; } + +/* API banner — same v1/v2 line the per-layer Traces tab renders. */ +.iq-api-banner { + padding: 7px 12px; border: 1px solid var(--sw-line); border-radius: 6px; + background: var(--sw-bg-2); color: var(--sw-fg-2); font-size: 11px; line-height: 1.5; +} +.iq-api-banner code { + font-family: var(--sw-mono); font-size: 10.5px; padding: 0 3px; + border-radius: 3px; background: var(--sw-bg-3); color: var(--sw-accent); +} +.iq-api-banner b { color: var(--sw-fg-0); } -.iq-result { flex: 1; min-height: 0; } +.iq-result { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.iq-result > .iq-list-card { flex: 1; } .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); } -.iq-split { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; gap: 12px; height: 100%; min-height: 0; } /* List pane — the same sw-card + header the per-layer Traces tab uses, - with the row list scrolling inside. */ + 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-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; } /* Custom-range reset (× back to presets). */
