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 72888e386893b65460d5a016615b4b5352f003f6 Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 23 16:33:49 2026 +0800 feat(explore): Trace inspect result body at parity with the Traces tab Extract the duration distribution scatter into a shared render/widgets/TraceDistribution.vue (click-to-select + drag-brush), reused by the per-layer Traces tab and Trace inspect. Bring Trace inspect to full parity: the "Traces / N" sw-card list header, an adaptive full-height result, a Custom time range (datetime-local → window.startMs/endMs, validated end-to-end), and the distribution above the list. type-check + lint + build green. --- .../src/features/operate/explore/ExploreView.vue | 127 ++++++++- apps/ui/src/i18n/locales/de.json | 1 + apps/ui/src/i18n/locales/en.json | 1 + apps/ui/src/i18n/locales/es.json | 1 + apps/ui/src/i18n/locales/fr.json | 1 + apps/ui/src/i18n/locales/ja.json | 1 + apps/ui/src/i18n/locales/ko.json | 1 + apps/ui/src/i18n/locales/pt.json | 1 + apps/ui/src/i18n/locales/zh-CN.json | 1 + apps/ui/src/layer/traces/LayerTracesView.vue | 254 ++---------------- apps/ui/src/render/widgets/TraceDistribution.vue | 291 +++++++++++++++++++++ 11 files changed, 441 insertions(+), 239 deletions(-) diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue index d092713..8cdfb54 100644 --- a/apps/ui/src/features/operate/explore/ExploreView.vue +++ b/apps/ui/src/features/operate/explore/ExploreView.vue @@ -36,6 +36,7 @@ import type { ExploreEntity, ExploreRequest, ExploreResolved, + ExploreWindow, NativeSpan, NativeTraceListResponse, NativeTraceListRow, @@ -46,6 +47,7 @@ import { useLayers } from '@/shell/useLayers'; import { useTraceDetail } from '@/layer/traces/useLayerTraces'; import TraceListPanel from '@/render/widgets/TraceListPanel.vue'; import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue'; +import TraceDistribution from '@/render/widgets/TraceDistribution.vue'; import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; const props = defineProps<{ kind: 'trace' | 'log' }>(); @@ -195,6 +197,30 @@ const cond = reactive({ const WINDOWS = [15, 30, 60, 180, 360, 720, 1440]; const LIMITS = [20, 30, 50, 100]; +// Custom time range — same sentinel-on-windowMinutes shape the per-layer +// Traces tab uses. When chosen, the Time select swaps to two +// datetime-local inputs and the query carries explicit epoch-ms bounds. +const CUSTOM_RANGE_SENTINEL = -1; +const customStart = ref<string | null>(null); +const customEnd = ref<string | null>(null); +const isCustomRange = computed(() => cond.windowMinutes === CUSTOM_RANGE_SENTINEL); +function fmtLocalInput(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +watch(isCustomRange, (custom) => { + if (custom) { + if (customStart.value && customEnd.value) return; + const end = new Date(); + const start = new Date(end.getTime() - 30 * 60_000); + customStart.value = fmtLocalInput(start); + customEnd.value = fmtLocalInput(end); + } else { + customStart.value = null; + customEnd.value = null; + } +}); + function parseTags(s: string): Array<{ key: string; value: string }> { return s .split(',') @@ -217,6 +243,25 @@ const showResolved = ref(false); const canRun = computed(() => props.kind === 'trace' && traceSource.value === 'native'); +/** Build the window the request carries: explicit epoch-ms bounds in + * Custom mode (datetime-local strings are browser-local; `Date.parse` + * reads them as local), else the rolling minutes preset. */ +function resolveWindow(): ExploreWindow { + if (isCustomRange.value) { + if (customStart.value && customEnd.value) { + const startMs = Date.parse(customStart.value); + const endMs = Date.parse(customEnd.value); + if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { + return { startMs, endMs }; + } + } + // Custom selected but bounds not yet valid — fall back to a sane + // rolling window rather than forwarding the -1 sentinel. + return { windowMinutes: 30 }; + } + return { windowMinutes: cond.windowMinutes }; +} + async function runQuery(): Promise<void> { running.value = true; hasQueried.value = true; @@ -227,7 +272,7 @@ async function runQuery(): Promise<void> { kind: 'trace', traceSource: 'native', ...(entity ? { entity } : {}), - window: { windowMinutes: cond.windowMinutes }, + window: resolveWindow(), pageSize: cond.limit, traceId: cond.traceId.trim() || undefined, traceState: cond.traceState, @@ -385,10 +430,19 @@ function changeSelectedTraceId(id: string): void { <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"> + <label class="cf" :class="{ 'cf-wide': isCustomRange }"> <span>{{ t('Time') }}</span> - <select v-model.number="cond.windowMinutes" class="cf-input"> + <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"> @@ -410,19 +464,41 @@ function changeSelectedTraceId(id: string): void { </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"> - <div class="iq-list-pane"> + <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" /> - </div> + </article> <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> @@ -444,7 +520,13 @@ function changeSelectedTraceId(id: string): void { </template> <style scoped> -.iq { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; padding: 14px 16px; } +/* Fill the viewport below the topbar so the result area (and the list / + detail inside it) flex to the available height instead of collapsing to + content. The shell `<main>` grows with content rather than imposing a + definite height, so a plain `height: 100%` would resolve to auto — the + viewport-anchored min-height gives the flex column real height to share + (52px ≈ the topbar row). */ +.iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh - 52px); padding: 14px 16px; } .iq-head h1 { font-size: 16px; margin: 0; } .iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; } .iq-coming { padding: 40px; text-align: center; color: var(--sw-fg-3); font-size: 13px; } @@ -486,11 +568,38 @@ 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; } -.iq-result { flex: 1; min-height: 0; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; } +/* 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; } +.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; } + +.iq-result { flex: 1; min-height: 0; } .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; height: 100%; min-height: 0; } -.iq-list-pane { display: flex; flex-direction: column; min-height: 0; overflow: auto; border-right: 1px solid var(--sw-line); } +.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. */ +.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); } .iq-detail { overflow: auto; min-width: 0; } + +/* Custom-range reset (× back to presets). */ +.iq-range-reset { + flex: 0 0 auto; height: 28px; width: 24px; padding: 0; + background: transparent; border: 1px solid var(--sw-line-2); border-radius: 4px; + color: var(--sw-fg-2); cursor: pointer; font-size: 13px; line-height: 1; +} +.iq-range-reset:hover { color: var(--sw-accent); border-color: var(--sw-accent); } </style> diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index b7239ee..9d9b0d3 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -516,6 +516,7 @@ "Clear in-page filter": "In-Page-Filter zurücksetzen", "Reset": "Zurücksetzen", "picked": "ausgewählt", + "selected": "ausgewählt", "no traces": "keine Traces", "This OAP serves traces via": "Dieses OAP liefert Traces über", "Trace Query {label} API": "Trace-Query-{label}-API", diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 8d77c6c..0b06fa3 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -486,6 +486,7 @@ "Clear in-page filter": "Clear in-page filter", "Reset": "Reset", "picked": "picked", + "selected": "selected", "no traces": "no traces", "This OAP serves traces via": "This OAP serves traces via", "Trace Query {label} API": "Trace Query {label} API", diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index a982d2b..3b5fcb2 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -516,6 +516,7 @@ "Clear in-page filter": "Limpiar filtro de la página", "Reset": "Restablecer", "picked": "seleccionadas", + "selected": "seleccionado", "no traces": "sin trazas", "This OAP serves traces via": "Este OAP sirve trazas a través de", "Trace Query {label} API": "API Trace Query {label}", diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index 42fa778..2617b2f 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -516,6 +516,7 @@ "Clear in-page filter": "Effacer le filtre de la page", "Reset": "Réinitialiser", "picked": "sélectionnés", + "selected": "sélectionné", "no traces": "aucune trace", "This OAP serves traces via": "Cet OAP sert les traces via", "Trace Query {label} API": "API Trace Query {label}", diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index a92e591..eb2d482 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -516,6 +516,7 @@ "Clear in-page filter": "ページ内フィルタをクリア", "Reset": "リセット", "picked": "選択中", + "selected": "選択済み", "no traces": "トレースなし", "This OAP serves traces via": "この OAP は次の経由でトレースを提供します:", "Trace Query {label} API": "Trace Query {label} API", diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index f98271d..7504748 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -516,6 +516,7 @@ "Clear in-page filter": "페이지 내 필터 지우기", "Reset": "재설정", "picked": "선택됨", + "selected": "선택됨", "no traces": "트레이스 없음", "This OAP serves traces via": "이 OAP는 다음을 통해 트레이스를 제공합니다:", "Trace Query {label} API": "Trace Query {label} API", diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 367a0a1..f781bfe 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -516,6 +516,7 @@ "Clear in-page filter": "Limpar filtro da página", "Reset": "Redefinir", "picked": "selecionado", + "selected": "selecionado", "no traces": "nenhum trace", "This OAP serves traces via": "Este OAP entrega traces via", "Trace Query {label} API": "API de consulta de traces {label}", diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index b9ae868..ae53d88 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -516,6 +516,7 @@ "Clear in-page filter": "清除页内筛选", "Reset": "重置", "picked": "已选", + "selected": "已选中", "no traces": "暂无 trace", "This OAP serves traces via": "此 OAP 通过以下方式提供 trace 数据:", "Trace Query {label} API": "Trace Query {label} API", diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue b/apps/ui/src/layer/traces/LayerTracesView.vue index b65cc9f..7b7a5c8 100644 --- a/apps/ui/src/layer/traces/LayerTracesView.vue +++ b/apps/ui/src/layer/traces/LayerTracesView.vue @@ -54,10 +54,10 @@ import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; import { useSelectedService } from '@/layer/useSelectedService'; import { useLayerServiceName } from '@/layer/useLayerServiceName'; import { useSetupStore } from '@/state/setup'; -import { fmtMetric } from '@/utils/formatters'; import { bffClient } from '@/api/client'; import TraceListPanel from '@/render/widgets/TraceListPanel.vue'; import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue'; +import TraceDistribution from '@/render/widgets/TraceDistribution.vue'; const route = useRoute(); const layerKey = computed(() => String(route.params.layerKey ?? '')); @@ -337,74 +337,22 @@ const { nativeDetail, isFetching: detailFetching } = useTraceDetail(traceIdRef, // when present, else the on-demand `queryTrace` fetch. const detailSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? nativeDetail.value?.spans ?? []); -// ── Render helpers ──────────────────────────────────────────────── -function fmtMs(v: number | null | undefined): string { - if (v === null || v === undefined) return '—'; - return `${fmtMetric(v)} ms`; -} -function parseNativeStart(v: string): number { - const n = Number(v); - if (Number.isFinite(n) && n > 0) return n; - const ts = Date.parse(v); - return Number.isFinite(ts) ? ts : 0; -} - const maxTraceDuration = computed(() => { const arr = visibleTraces.value; if (arr.length === 0) return 0; return Math.max(...arr.map((tr) => tr.duration)); }); -// ── Duration scatter ────────────────────────────────────────────── -interface ScatterPoint { id: string; rowKey: string; x: number; y: number; isError: boolean; label: string; row: NativeTraceListRow; } -const scatterPoints = computed<ScatterPoint[]>(() => { - const out: ScatterPoint[] = []; - if (native.value?.traces) { - for (const t of native.value.traces) { - const ts = parseNativeStart(t.start); - out.push({ - id: t.key, - rowKey: t.key, - x: ts, - y: t.duration, - isError: t.isError, - label: t.endpointNames[0] ?? '—', - row: t, - }); - } - } - return out; -}); -const scatterBounds = computed(() => { - const pts = scatterPoints.value.filter((p) => p.x > 0 && Number.isFinite(p.y)); - if (pts.length === 0) return null; - const xs = pts.map((p) => p.x); - const ys = pts.map((p) => p.y); - const rawYMax = Math.max(...ys, 1); - const yMax = rawYMax || 1; - return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax }; -}); -const scatterXTicks = computed(() => { - const b = scatterBounds.value; - if (!b) return []; - const xCount = 3; - const span = Math.max(1, b.xMax - b.xMin); - return Array.from({ length: xCount }, (_, i) => { - const t = b.xMin + (span * i) / (xCount - 1); - const d = new Date(t); - const pad = (n: number) => String(n).padStart(2, '0'); - return { frac: i / (xCount - 1), label: `${pad(d.getHours())}:${pad(d.getMinutes())}` }; - }); -}); - /* ── Distribution selection (in-page filter) ───────────────────── * * Clicking a dot adds (or removes) the corresponding trace to the * `pickedTraceIds` set. Dragging a rectangle on the chart selects - * every dot inside it. The list below is then filtered to only the - * picked traces — no extra query fires. Reset clears the set. + * every dot inside it (the shared TraceDistribution emits the matched + * keys). The list below is then filtered to only the picked traces — + * no extra query fires. Reset clears the set. */ const pickedTraceIds = ref<Set<string>>(new Set()); +const pickedKeys = computed(() => [...pickedTraceIds.value]); const isPicking = computed(() => pickedTraceIds.value.size > 0); function togglePick(rowKey: string): void { const s = new Set(pickedTraceIds.value); @@ -415,83 +363,17 @@ function togglePick(rowKey: string): void { function resetPick(): void { pickedTraceIds.value = new Set(); } - -// Drag-to-select state. Coords are SVG-space (viewBox 0..1000 in x, -// 1000..0 in y). The drag rect renders while pointer is down. -const scatterSvgRef = ref<SVGSVGElement | null>(null); -const dragState = ref<{ - active: boolean; - startVx: number; startVy: number; curVx: number; curVy: number; -}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }); -function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null { - const svg = scatterSvgRef.value; - if (!svg) return null; - const rect = svg.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return null; - const vx = ((ev.clientX - rect.left) / rect.width) * 1000; - const vy = ((ev.clientY - rect.top) / rect.height) * 1000; - return { vx, vy }; -} -function onScatterDown(ev: PointerEvent): void { - const pt = clientToViewbox(ev); - if (!pt) return; - const target = ev.target as Element | null; - // Click on a dot is handled by its own click handler; the drag is - // for the empty plot area. - if (target?.classList.contains('scatter-dot')) return; - ev.preventDefault(); - dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: pt.vx, curVy: pt.vy }; - (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId); -} -function onScatterMove(ev: PointerEvent): void { - if (!dragState.value.active) return; - const pt = clientToViewbox(ev); - if (!pt) return; - dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy }; +// Dot click → toggle picked. The operator picks dots to filter; the +// inline detail still opens via the list row click below. +function onScatterSelect(row: NativeTraceListRow): void { + togglePick(row.key); } -function onScatterUp(ev: PointerEvent): void { - if (!dragState.value.active) return; - const { startVx, startVy, curVx, curVy } = dragState.value; - dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }; - try { - (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId); - } catch { /* noop */ } - // No real drag (click without move) → don't change selection. - if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return; - const b = scatterBounds.value; - if (!b) return; - const vxMin = Math.min(startVx, curVx); - const vxMax = Math.max(startVx, curVx); - const vyMin = Math.min(startVy, curVy); - const vyMax = Math.max(startVy, curVy); +// Drag-brush → add every matched dot to the picked set. +function onScatterBrush(keys: string[]): void { const next = new Set(pickedTraceIds.value); - for (const p of scatterPoints.value) { - const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) * 1000; - const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990; - if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) { - next.add(p.rowKey); - } - } + for (const k of keys) next.add(k); pickedTraceIds.value = next; } -const dragRect = computed(() => { - const s = dragState.value; - if (!s.active) return null; - return { - x: Math.min(s.startVx, s.curVx), - y: Math.min(s.startVy, s.curVy), - w: Math.abs(s.curVx - s.startVx), - h: Math.abs(s.curVy - s.startVy), - }; -}); - -// Dot click → toggle picked. Replaces the earlier "open inline -// detail" wiring: the operator picks dots to filter; the inline -// detail still opens via the list row click below. -function pickScatterDot(p: ScatterPoint, ev: MouseEvent): void { - ev.stopPropagation(); - togglePick(p.rowKey); -} // Filter the visible list by the picked set when any are picked. const visibleTraces = computed(() => { @@ -673,60 +555,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) @click="resetPick" >{{ t('Reset') }}</button> </header> - <div v-if="scatterPoints.length > 0 && scatterBounds" class="scatter-wrap"> - <svg - ref="scatterSvgRef" - class="scatter-svg" - viewBox="0 0 1000 1000" - preserveAspectRatio="none" - @pointerdown="onScatterDown" - @pointermove="onScatterMove" - @pointerup="onScatterUp" - @pointercancel="onScatterUp" - > - <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)" stroke-width="1" vector-effect="non-scaling-stroke" /> - <circle - v-for="p in scatterPoints" - :key="p.id" - :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x - scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000" - :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax - scatterBounds.yMin || 1)) * 990" - :r="pickedTraceIds.has(p.rowKey) ? 6 : 3.2" - :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'" - :fill-opacity="pickedTraceIds.has(p.rowKey) ? 1 : (isPicking ? 0.35 : 0.9)" - :stroke="pickedTraceIds.has(p.rowKey) ? 'var(--sw-fg-0)' : (p.isError ? 'var(--sw-err)' : 'var(--sw-accent-2)')" - :stroke-width="pickedTraceIds.has(p.rowKey) ? 1.8 : 0.8" - vector-effect="non-scaling-stroke" - class="scatter-dot" - @click="pickScatterDot(p, $event)" - > - <title>{{ p.label }} · {{ fmtMs(p.y) }}{{ pickedTraceIds.has(p.rowKey) ? ` · ${t('picked')}` : '' }}</title> - </circle> - <!-- Drag-select rectangle. --> - <rect - v-if="dragRect" - :x="dragRect.x" - :y="dragRect.y" - :width="dragRect.w" - :height="dragRect.h" - fill="var(--sw-accent)" - fill-opacity="0.12" - stroke="var(--sw-accent)" - stroke-width="1" - stroke-dasharray="4 3" - vector-effect="non-scaling-stroke" - pointer-events="none" - /> - </svg> - <div class="scatter-x-axis"> - <span - v-for="(t, i) in scatterXTicks" - :key="`x${i}`" - class="x-tick" - :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }" - >{{ t.label }}</span> - </div> - </div> - <div v-else class="scatter-empty">{{ t('no traces') }}</div> + <TraceDistribution + :rows="native?.traces ?? []" + :max-duration="maxTraceDuration" + :selected-key="null" + :highlight-keys="pickedKeys" + @select="onScatterSelect" + @brush="onScatterBrush" + /> </section> </div> @@ -930,57 +766,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) } .tr-scatter-head .lg.ok { background: var(--sw-accent); } .tr-scatter-head .lg.err { background: var(--sw-err); } -.scatter-tools { margin-left: 6px; display: inline-flex; gap: 4px; } .pick-kicker { color: var(--sw-accent-2); font-weight: 700; } .reset-btn { margin-left: 6px; } -.pick-hint { - background: var(--sw-accent-soft); - color: var(--sw-accent-2); - padding: 1px 6px; - border-radius: 8px; - font-weight: 600; -} -.scatter-svg { cursor: crosshair; } -.scatter-dot { cursor: pointer; } -.scatter-wrap { - padding: 0; - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} -.scatter-svg { - width: 100%; - flex: 1; - min-height: 140px; - display: block; -} -.scatter-dot { cursor: pointer; transition: r 0.12s ease; } -.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; } -.scatter-x-axis { - display: flex; - justify-content: space-between; - padding: 4px 0 0; - font-size: 11px; - color: var(--sw-fg-2); - font-family: var(--sw-mono); - flex: 0 0 auto; -} -.x-tick { white-space: nowrap; } -.x-tick.first { text-align: left; } -.x-tick.last { text-align: right; } -.scatter-empty { - flex: 1; - min-height: 140px; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - color: var(--sw-fg-3); -} +/* The scatter chart internals (dots, axis, drag) live in + TraceDistribution.vue; this card only styles the head strip. The + 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; } diff --git a/apps/ui/src/render/widgets/TraceDistribution.vue b/apps/ui/src/render/widgets/TraceDistribution.vue new file mode 100644 index 0000000..4a6458e --- /dev/null +++ b/apps/ui/src/render/widgets/TraceDistribution.vue @@ -0,0 +1,291 @@ +<!-- + 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. +--> +<!-- + Shared trace duration-distribution scatter. The single dot-plot used by + BOTH the per-layer Traces tab and the cross-layer Trace inspect view: + X is the trace start time, Y the trace duration. The Y axis is omitted — + the duration is surfaced on the dot's tooltip and the list-row bar below. + + Two operator gestures, both emitted upward so the host decides the + effect: + - Click a dot → `select(row)`. (Traces tab toggles its in-page pick + filter; Trace inspect opens the trace detail.) + - Drag a region → `brush(keys)` with every row.key whose dot falls in + the rectangle. (Traces tab merges them into the pick set; hosts that + don't filter in-page simply ignore it.) + + Presentational only: no selection / filter state lives here. `selectedKey` + (the host's active/picked row) drives the highlighted dot rendering; the + host owns the truth. +--> +<script setup lang="ts"> +import { computed, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { NativeTraceListRow } from '@/api/client'; +import { fmtMetric } from '@/utils/formatters'; + +const { t } = useI18n({ useScope: 'global' }); + +const props = withDefaults( + defineProps<{ + rows: NativeTraceListRow[]; + maxDuration: number; + /** The host's active/picked row key. Renders the matching dot enlarged + * + outlined; null dims nothing. */ + selectedKey: string | null; + /** Optional multi-highlight set (the Traces tab's in-page pick set). + * Every dot whose key is here renders enlarged + outlined, and the + * rest dim — the brush picks more than one trace at a time. When + * empty the `selectedKey` single-highlight applies instead. */ + highlightKeys?: string[]; + }>(), + { highlightKeys: () => [] }, +); +const emit = defineEmits<{ + (e: 'select', row: NativeTraceListRow): void; + (e: 'brush', keys: string[]): void; +}>(); + +const highlightSet = computed(() => new Set(props.highlightKeys)); +/** A dot is highlighted when it's the single `selectedKey` or a member of + * the multi-highlight set. */ +function isHot(rowKey: string): boolean { + return props.selectedKey === rowKey || highlightSet.value.has(rowKey); +} +/** True when something is highlighted (single or set) — non-hot dots dim. */ +const hasHot = computed(() => props.selectedKey !== null || highlightSet.value.size > 0); + +function fmtMs(v: number | null | undefined): string { + if (v === null || v === undefined) return '—'; + return `${fmtMetric(v)} ms`; +} +function parseNativeStart(v: string): number { + const n = Number(v); + if (Number.isFinite(n) && n > 0) return n; + const ts = Date.parse(v); + return Number.isFinite(ts) ? ts : 0; +} + +interface ScatterPoint { id: string; rowKey: string; x: number; y: number; isError: boolean; label: string; row: NativeTraceListRow; } +const scatterPoints = computed<ScatterPoint[]>(() => { + const out: ScatterPoint[] = []; + for (const tr of props.rows) { + const ts = parseNativeStart(tr.start); + out.push({ + id: tr.key, + rowKey: tr.key, + x: ts, + y: tr.duration, + isError: tr.isError, + label: tr.endpointNames[0] ?? '—', + row: tr, + }); + } + return out; +}); +const scatterBounds = computed(() => { + const pts = scatterPoints.value.filter((p) => p.x > 0 && Number.isFinite(p.y)); + if (pts.length === 0) return null; + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + const rawYMax = Math.max(...ys, 1); + const yMax = rawYMax || 1; + return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax }; +}); +const scatterXTicks = computed(() => { + const b = scatterBounds.value; + if (!b) return []; + const xCount = 3; + const span = Math.max(1, b.xMax - b.xMin); + return Array.from({ length: xCount }, (_, i) => { + const at = b.xMin + (span * i) / (xCount - 1); + const d = new Date(at); + const pad = (n: number) => String(n).padStart(2, '0'); + return { frac: i / (xCount - 1), label: `${pad(d.getHours())}:${pad(d.getMinutes())}` }; + }); +}); + +// Drag-to-select state. Coords are SVG-space (viewBox 0..1000 in x, +// 1000..0 in y). The drag rect renders while pointer is down. +const scatterSvgRef = ref<SVGSVGElement | null>(null); +const dragState = ref<{ + active: boolean; + startVx: number; startVy: number; curVx: number; curVy: number; +}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }); +function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null { + const svg = scatterSvgRef.value; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + const vx = ((ev.clientX - rect.left) / rect.width) * 1000; + const vy = ((ev.clientY - rect.top) / rect.height) * 1000; + return { vx, vy }; +} +function onScatterDown(ev: PointerEvent): void { + const pt = clientToViewbox(ev); + if (!pt) return; + const target = ev.target as Element | null; + // Click on a dot is handled by its own click handler; the drag is + // for the empty plot area. + if (target?.classList.contains('scatter-dot')) return; + ev.preventDefault(); + dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: pt.vx, curVy: pt.vy }; + (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId); +} +function onScatterMove(ev: PointerEvent): void { + if (!dragState.value.active) return; + const pt = clientToViewbox(ev); + if (!pt) return; + dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy }; +} +function onScatterUp(ev: PointerEvent): void { + if (!dragState.value.active) return; + const { startVx, startVy, curVx, curVy } = dragState.value; + dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }; + try { + (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId); + } catch { /* noop */ } + // No real drag (click without move) → don't change selection. + if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return; + const b = scatterBounds.value; + if (!b) return; + const vxMin = Math.min(startVx, curVx); + const vxMax = Math.max(startVx, curVx); + const vyMin = Math.min(startVy, curVy); + const vyMax = Math.max(startVy, curVy); + const keys: string[] = []; + for (const p of scatterPoints.value) { + const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) * 1000; + const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990; + if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) { + keys.push(p.rowKey); + } + } + if (keys.length > 0) emit('brush', keys); +} +const dragRect = computed(() => { + const s = dragState.value; + if (!s.active) return null; + return { + x: Math.min(s.startVx, s.curVx), + y: Math.min(s.startVy, s.curVy), + w: Math.abs(s.curVx - s.startVx), + h: Math.abs(s.curVy - s.startVy), + }; +}); +function onScatterDot(p: ScatterPoint, ev: MouseEvent): void { + ev.stopPropagation(); + emit('select', p.row); +} +</script> + +<template> + <div v-if="scatterPoints.length > 0 && scatterBounds" class="scatter-wrap"> + <svg + ref="scatterSvgRef" + class="scatter-svg" + viewBox="0 0 1000 1000" + preserveAspectRatio="none" + @pointerdown="onScatterDown" + @pointermove="onScatterMove" + @pointerup="onScatterUp" + @pointercancel="onScatterUp" + > + <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)" stroke-width="1" vector-effect="non-scaling-stroke" /> + <circle + v-for="p in scatterPoints" + :key="p.id" + :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x - scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000" + :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax - scatterBounds.yMin || 1)) * 990" + :r="isHot(p.rowKey) ? 6 : 3.2" + :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'" + :fill-opacity="isHot(p.rowKey) ? 1 : (hasHot ? 0.35 : 0.9)" + :stroke="isHot(p.rowKey) ? 'var(--sw-fg-0)' : (p.isError ? 'var(--sw-err)' : 'var(--sw-accent-2)')" + :stroke-width="isHot(p.rowKey) ? 1.8 : 0.8" + vector-effect="non-scaling-stroke" + class="scatter-dot" + @click="onScatterDot(p, $event)" + > + <title>{{ p.label }} · {{ fmtMs(p.y) }}{{ isHot(p.rowKey) ? ` · ${t('selected')}` : '' }}</title> + </circle> + <!-- Drag-select rectangle. --> + <rect + v-if="dragRect" + :x="dragRect.x" + :y="dragRect.y" + :width="dragRect.w" + :height="dragRect.h" + fill="var(--sw-accent)" + fill-opacity="0.12" + stroke="var(--sw-accent)" + stroke-width="1" + stroke-dasharray="4 3" + vector-effect="non-scaling-stroke" + pointer-events="none" + /> + </svg> + <div class="scatter-x-axis"> + <span + v-for="(tick, i) in scatterXTicks" + :key="`x${i}`" + class="x-tick" + :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }" + >{{ tick.label }}</span> + </div> + </div> + <div v-else class="scatter-empty">{{ t('no traces') }}</div> +</template> + +<style scoped> +.scatter-svg { cursor: crosshair; } +.scatter-wrap { + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} +.scatter-svg { + width: 100%; + flex: 1; + min-height: 140px; + display: block; +} +.scatter-dot { cursor: pointer; transition: r 0.12s ease; } +.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; } +.scatter-x-axis { + display: flex; + justify-content: space-between; + padding: 4px 0 0; + font-size: 11px; + color: var(--sw-fg-2); + font-family: var(--sw-mono); + flex: 0 0 auto; +} +.x-tick { white-space: nowrap; } +.x-tick.first { text-align: left; } +.x-tick.last { text-align: right; } +.scatter-empty { + flex: 1; + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--sw-fg-3); +} +</style>
