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 9c71278cd21dda307b027dcdd7cffe75f9d4dad7
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 23 20:39:34 2026 +0800

    feat(explore): add Zipkin source to Trace inspect
    
    - Source toggle now switches the cross-layer Trace inspect between Native
      and Zipkin. Zipkin picks a service from the global Zipkin index (no
      layer/id), with optional remote-service + span filters and an
      annotation-query condition; native-only conditions (status/order/tags)
      are hidden.
    - BFF runs the full Zipkin REST query (remoteService/span/annotation/
      duration/window/limit) against oap.zipkinUrl — not the GraphQL port,
      which 404s. Soft-fails to an unreachable envelope.
    - Extract the Zipkin span waterfall + detail into a shared
      ZipkinTraceDetailCard reused by the per-layer Zipkin tab and inspect;
      Zipkin rows adapt onto the shared trace list + distribution.
---
 apps/bff/src/client/zipkin.ts                      |   5 +
 apps/bff/src/http/query/explore.ts                 |  58 +-
 .../src/features/operate/explore/ExploreView.vue   | 223 ++++++-
 apps/ui/src/i18n/locales/de.json                   |   2 +
 apps/ui/src/i18n/locales/en.json                   |   2 +
 apps/ui/src/i18n/locales/es.json                   |   2 +
 apps/ui/src/i18n/locales/fr.json                   |   2 +
 apps/ui/src/i18n/locales/ja.json                   |   2 +
 apps/ui/src/i18n/locales/ko.json                   |   2 +
 apps/ui/src/i18n/locales/pt.json                   |   2 +
 apps/ui/src/i18n/locales/zh-CN.json                |   2 +
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 596 +------------------
 .../src/render/widgets/ZipkinTraceDetailCard.vue   | 662 +++++++++++++++++++++
 13 files changed, 956 insertions(+), 604 deletions(-)

diff --git a/apps/bff/src/client/zipkin.ts b/apps/bff/src/client/zipkin.ts
index c7db428..af6a0b8 100644
--- a/apps/bff/src/client/zipkin.ts
+++ b/apps/bff/src/client/zipkin.ts
@@ -51,7 +51,10 @@ export interface ZipkinClientOpts {
 
 export interface ZipkinTracesQuery {
   serviceName?: string;
+  remoteServiceName?: string;
   spanName?: string;
+  /** Zipkin annotation query — `key` / `key=value`, AND-joined. */
+  annotationQuery?: string;
   /** Microseconds. */
   minDuration?: number;
   /** Microseconds. */
@@ -140,7 +143,9 @@ export async function zipkinFetchTraces(
 ): Promise<ZipkinTraceListRow[]> {
   const qs = new URLSearchParams();
   if (query.serviceName) qs.set('serviceName', query.serviceName);
+  if (query.remoteServiceName) qs.set('remoteServiceName', 
query.remoteServiceName);
   if (query.spanName) qs.set('spanName', query.spanName);
+  if (query.annotationQuery) qs.set('annotationQuery', query.annotationQuery);
   if (typeof query.minDuration === 'number') qs.set('minDuration', 
String(query.minDuration));
   if (typeof query.maxDuration === 'number') qs.set('maxDuration', 
String(query.maxDuration));
   const endTs = query.endTs ?? Date.now();
diff --git a/apps/bff/src/http/query/explore.ts 
b/apps/bff/src/http/query/explore.ts
index ee78f4c..7cc3ed1 100644
--- a/apps/bff/src/http/query/explore.ts
+++ b/apps/bff/src/http/query/explore.ts
@@ -35,6 +35,7 @@ import type {
   ExploreResponse,
   ExploreWindow,
   FetchLike,
+  ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
@@ -42,7 +43,8 @@ import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
 import { getServerOffsetMinutes } from '../../util/window.js';
 import { buildEndpointId, buildInstanceId, buildServiceId } from 
'../../util/entityId.js';
-import { fetchNativeList, fetchZipkinList, type TraceListBody } from 
'./trace.js';
+import { fetchNativeList, type TraceListBody } from './trace.js';
+import { zipkinFetchTraces } from '../../client/zipkin.js';
 
 export interface ExploreRouteDeps {
   config: ConfigSource;
@@ -76,6 +78,16 @@ function traceWindowFields(w: ExploreWindow): 
Pick<TraceListBody, 'windowMinutes
   return { windowMinutes: w.windowMinutes };
 }
 
+/** Zipkin queries the window `[endTs - lookback, endTs]` (ms). Explicit
+ *  epoch-ms bounds map to `endTs = endMs`, `lookback = endMs - startMs`;
+ *  the rolling minutes preset maps to `endTs = now`, `lookback = mins`. */
+function zipkinWindow(w: ExploreWindow): { endTs: number; lookback: number } {
+  if (typeof w.startMs === 'number' && typeof w.endMs === 'number' && w.endMs 
> w.startMs) {
+    return { endTs: w.endMs, lookback: w.endMs - w.startMs };
+  }
+  return { endTs: Date.now(), lookback: Math.max(1, w.windowMinutes ?? 30) * 
60_000 };
+}
+
 export function registerExploreRoutes(app: FastifyInstance, deps: 
ExploreRouteDeps): void {
   const auth = requireAuth(deps);
 
@@ -138,19 +150,51 @@ export function registerExploreRoutes(app: 
FastifyInstance, deps: ExploreRouteDe
           } satisfies ExploreResponse);
         }
 
-        // zipkin: a raw service name (no OAP id). remoteService/span/
-        // annotation enrichment lands with the zipkin form increment.
+        // zipkin: a raw service name (no OAP id) plus the rich query
+        // params Zipkin's REST API takes. Duration arrives in ms (the
+        // shared condition unit) — Zipkin wants µs.
         const service = body.entity?.serviceName;
-        const zipkin = await fetchZipkinList(opts, { ...base, service }, 
maxTraces);
+        const { endTs, lookback } = zipkinWindow(body.window ?? {});
+        const minUs = typeof body.minTraceDuration === 'number' ? Math.max(0, 
body.minTraceDuration * 1000) : undefined;
+        const maxUs = typeof body.maxTraceDuration === 'number' ? Math.max(0, 
body.maxTraceDuration * 1000) : undefined;
+        const limit = Math.min(maxTraces, Math.max(1, Math.round(body.pageSize 
?? 20)));
+        const zipkinOpts = { ...opts, queryUrl: 
deps.config.current.oap.zipkinUrl };
+        let zipkin: ZipkinTraceListResponse;
+        try {
+          const traces = await zipkinFetchTraces(zipkinOpts, {
+            serviceName: service,
+            remoteServiceName: body.remoteServiceName || undefined,
+            spanName: body.spanName || undefined,
+            annotationQuery: body.annotationQuery || undefined,
+            minDuration: minUs,
+            maxDuration: maxUs,
+            endTs,
+            lookback,
+            limit,
+          });
+          zipkin = { source: 'zipkin', traces, reachable: true };
+        } catch (err) {
+          zipkin = {
+            source: 'zipkin',
+            traces: [],
+            reachable: false,
+            error: err instanceof Error ? err.message : String(err),
+          };
+        }
         const resolved: ExploreResolved = {
           kind: 'trace',
           source: 'zipkin',
           entityId: service,
           condition: {
             ...(service ? { serviceName: service } : {}),
-            ...(typeof body.minTraceDuration === 'number' ? { minDuration: 
body.minTraceDuration } : {}),
-            ...(typeof body.maxTraceDuration === 'number' ? { maxDuration: 
body.maxTraceDuration } : {}),
-            ...win,
+            ...(body.remoteServiceName ? { remoteServiceName: 
body.remoteServiceName } : {}),
+            ...(body.spanName ? { spanName: body.spanName } : {}),
+            ...(body.annotationQuery ? { annotationQuery: body.annotationQuery 
} : {}),
+            ...(typeof minUs === 'number' ? { minDuration: minUs } : {}),
+            ...(typeof maxUs === 'number' ? { maxDuration: maxUs } : {}),
+            endTs,
+            lookback,
+            limit,
           },
         };
         return reply.send({
diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue 
b/apps/ui/src/features/operate/explore/ExploreView.vue
index 4421254..d35824c 100644
--- a/apps/ui/src/features/operate/explore/ExploreView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreView.vue
@@ -43,10 +43,13 @@ import type {
   TraceQueryOrder,
   TraceQueryState,
 } from '@/api/client';
+import type { ZipkinTraceListRow } from '@/api/client';
 import { useLayers } from '@/shell/useLayers';
 import { useTraceDetail } from '@/layer/traces/useLayerTraces';
+import { useZipkinTrace } from '@/layer/traces/useZipkinTraces';
 import TraceListPanel from '@/render/widgets/TraceListPanel.vue';
 import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue';
+import ZipkinTraceDetailCard from '@/render/widgets/ZipkinTraceDetailCard.vue';
 import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
 
@@ -183,6 +186,74 @@ function currentEntity(): ExploreEntity | null {
   };
 }
 
+// ── zipkin entity (raw service universe — no layer, no id) ────────────
+const zipkinService = ref<string>('');
+const zipkinRemote = ref<string>('');
+const zipkinSpan = ref<string>('');
+const zipkinServiceNames = ref<string[]>([]);
+const zipkinRemoteNames = ref<string[]>([]);
+const zipkinSpanNames = ref<string[]>([]);
+
+const zipkinServiceOptions = computed(() => [
+  { value: '', label: t('All services') },
+  ...zipkinServiceNames.value.map((s) => ({ value: s, label: s })),
+]);
+const zipkinRemoteOptions = computed(() => [
+  { value: '', label: t('any') },
+  ...zipkinRemoteNames.value.map((s) => ({ value: s, label: s })),
+]);
+const zipkinSpanOptions = computed(() => [
+  { value: '', label: t('any') },
+  ...zipkinSpanNames.value.map((s) => ({ value: s, label: s })),
+]);
+const zipkinHasService = computed(() => zipkinService.value.trim().length > 0);
+
+async function loadZipkinServices(): Promise<void> {
+  try {
+    const res = await bffClient.zipkin.services();
+    zipkinServiceNames.value = Array.from(new Set(Array.isArray(res) ? res : 
[]));
+  } catch {
+    zipkinServiceNames.value = [];
+  }
+}
+async function loadZipkinAutocomplete(svc: string): Promise<void> {
+  if (!svc) {
+    zipkinRemoteNames.value = [];
+    zipkinSpanNames.value = [];
+    return;
+  }
+  try {
+    const sp = await bffClient.zipkin.spans(svc);
+    zipkinSpanNames.value = Array.isArray(sp) ? sp : [];
+  } catch { zipkinSpanNames.value = []; }
+  try {
+    const rs = await bffClient.zipkin.remoteServices(svc);
+    zipkinRemoteNames.value = Array.isArray(rs) ? rs : [];
+  } catch { zipkinRemoteNames.value = []; }
+}
+// Span / remote autocomplete is service-scoped in Zipkin; clearing the
+// service resets the dependent fields so a stale span/remote doesn't
+// silently filter out everything against "All services".
+watch(zipkinService, (svc) => {
+  const trimmed = svc.trim();
+  if (!trimmed) {
+    zipkinRemote.value = '';
+    zipkinSpan.value = '';
+  }
+  void loadZipkinAutocomplete(trimmed);
+});
+// Load the zipkin service list the first time the operator switches to
+// the Zipkin source (and reset the result + detail on every switch).
+watch(traceSource, (src) => {
+  closeDetail();
+  hasQueried.value = false;
+  native.value = null;
+  zipkinRows.value = [];
+  resolved.value = null;
+  errorMsg.value = null;
+  if (src === 'zipkin' && zipkinServiceNames.value.length === 0) void 
loadZipkinServices();
+});
+
 // ── trace conditions ──────────────────────────────────────────────────
 const cond = reactive({
   traceId: '',
@@ -191,6 +262,7 @@ const cond = reactive({
   minDuration: '' as string,
   maxDuration: '' as string,
   tags: '' as string,
+  annotationQuery: '' as string,
   windowMinutes: 30,
   limit: 30,
 });
@@ -238,10 +310,11 @@ const running = ref(false);
 const hasQueried = ref(false);
 const errorMsg = ref<string | null>(null);
 const native = ref<NativeTraceListResponse | null>(null);
+const zipkinRows = ref<ZipkinTraceListRow[]>([]);
 const resolved = ref<ExploreResolved | null>(null);
 const showResolved = ref(false);
 
-const canRun = computed(() => props.kind === 'trace' && traceSource.value === 
'native');
+const canRun = computed(() => props.kind === 'trace');
 
 /** Build the window the request carries: explicit epoch-ms bounds in
  *  Custom mode (datetime-local strings are browser-local; `Date.parse`
@@ -262,13 +335,9 @@ function resolveWindow(): ExploreWindow {
   return { windowMinutes: cond.windowMinutes };
 }
 
-async function runQuery(): Promise<void> {
-  running.value = true;
-  hasQueried.value = true;
-  errorMsg.value = null;
-  closeDetail();
+function buildNativeRequest(): ExploreRequest {
   const entity = currentEntity();
-  const req: ExploreRequest = {
+  return {
     kind: 'trace',
     traceSource: 'native',
     ...(entity ? { entity } : {}),
@@ -281,31 +350,87 @@ async function runQuery(): Promise<void> {
     maxTraceDuration: cond.maxDuration ? Number(cond.maxDuration) : undefined,
     tags: parseTags(cond.tags),
   };
+}
+
+/** Zipkin entity carries the raw service name (no layer, no id); the
+ *  remote-service / span / annotation conditions ride on the request. */
+function buildZipkinRequest(): ExploreRequest {
+  const svc = zipkinService.value.trim();
+  return {
+    kind: 'trace',
+    traceSource: 'zipkin',
+    ...(svc ? { entity: { mode: 'type', serviceName: svc } } : {}),
+    window: resolveWindow(),
+    pageSize: cond.limit,
+    remoteServiceName: zipkinRemote.value.trim() || undefined,
+    spanName: zipkinSpan.value.trim() || undefined,
+    annotationQuery: cond.annotationQuery.trim() || undefined,
+    minTraceDuration: cond.minDuration ? Number(cond.minDuration) : undefined,
+    maxTraceDuration: cond.maxDuration ? Number(cond.maxDuration) : undefined,
+  };
+}
+
+async function runQuery(): Promise<void> {
+  running.value = true;
+  hasQueried.value = true;
+  errorMsg.value = null;
+  closeDetail();
+  const zipkin = traceSource.value === 'zipkin';
+  const req = zipkin ? buildZipkinRequest() : buildNativeRequest();
   try {
     const res = await bffClient.explore.query(req);
     if (res.kind === 'trace' && res.traceSource === 'native') {
       native.value = res.native;
+      zipkinRows.value = [];
       resolved.value = res.resolved;
       if (!res.native.reachable) errorMsg.value = res.native.error ?? t('OAP 
unreachable');
+    } else if (res.kind === 'trace' && res.traceSource === 'zipkin') {
+      zipkinRows.value = res.zipkin.traces;
+      native.value = null;
+      resolved.value = res.resolved;
+      if (!res.zipkin.reachable) errorMsg.value = res.zipkin.error ?? t('OAP 
unreachable');
     }
   } catch (e) {
     errorMsg.value = e instanceof Error ? e.message : String(e);
     native.value = null;
+    zipkinRows.value = [];
   } finally {
     running.value = false;
   }
 }
 
-const rows = computed<NativeTraceListRow[]>(() => (hasQueried.value ? 
native.value?.traces ?? [] : []));
+const isZipkin = computed(() => traceSource.value === 'zipkin');
+
+/** Adapt a Zipkin list row to the shared list/distribution row model.
+ *  Zipkin durations + timestamps are µs; the shared widgets read ms. */
+function zipkinToNativeRow(r: ZipkinTraceListRow): NativeTraceListRow {
+  return {
+    key: r.traceId,
+    segmentId: r.traceId,
+    endpointNames: [r.rootName ?? r.rootService ?? '—'],
+    duration: Math.round((r.duration ?? 0) / 1000),
+    start: String(Math.round((r.timestamp ?? 0) / 1000)),
+    isError: r.errorCount > 0,
+    traceIds: [r.traceId],
+  };
+}
+
+const rows = computed<NativeTraceListRow[]>(() => {
+  if (!hasQueried.value) return [];
+  return isZipkin.value
+    ? zipkinRows.value.map(zipkinToNativeRow)
+    : 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');
+// Zipkin always returns whole traces, so the banner is native-only.
+const isSegmentList = computed(() => !isZipkin.value && native.value?.api === 
'queryBasicTraces');
 const traceApiLabel = computed(() => (native.value?.api === 'queryTraces' ? 
'v2' : 'v1'));
-const showApiBanner = computed(() => hasQueried.value && 
!!native.value?.reachable);
+const showApiBanner = computed(() => hasQueried.value && !isZipkin.value && 
!!native.value?.reachable);
 
 // ── detail (reuses the shared trace-detail card + GET /api/trace/:id) ─
 const selectedTraceId = ref<string | null>(null);
@@ -314,8 +439,15 @@ 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);
+// Two detail hooks, each fed by a SOURCE-SCOPED trace-id ref so only the
+// active backend fetches (each hook gates on `!!traceId`). Native uses the
+// segment-id → queryTrace path; the zipkin detail is layer-less.
+const nativeDetailTraceId = computed<string | null>(() => (isZipkin.value ? 
null : selectedTraceId.value));
+const zipkinDetailTraceId = computed<string | null>(() => (isZipkin.value ? 
selectedTraceId.value : null));
+const { nativeDetail, isFetching: detailFetching } = 
useTraceDetail(nativeDetailTraceId, sourceRef);
+const { spans: zipkinSpans, isFetching: zipkinDetailFetching } = 
useZipkinTrace(zipkinDetailTraceId);
 const waterfallSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? 
nativeDetail.value?.spans ?? []);
+const detailLoading = computed(() => (isZipkin.value ? 
zipkinDetailFetching.value : detailFetching.value));
 
 function openRow(row: NativeTraceListRow): void {
   selectedRowKey.value = row.key;
@@ -334,7 +466,9 @@ function changeSelectedTraceId(id: string): void {
   embeddedSpans.value = null;
 }
 
-const detailCard = ref<InstanceType<typeof TraceDetailCard> | null>(null);
+// Both detail cards (native + zipkin) expose `closeSpanModal`; only one
+// mounts at a time, so a single ref drives the shared Esc cascade.
+const detailCard = ref<{ closeSpanModal: () => void } | null>(null);
 const spanModalOpen = ref<boolean>(false);
 function onPageKeyDown(e: KeyboardEvent): void {
   if (e.key !== 'Escape') return;
@@ -366,7 +500,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
         <span class="iq-bar-l">{{ t('Source') }}</span>
         <div class="seg">
           <button :class="{ on: traceSource === 'native' }" 
@click="traceSource = 'native'">{{ t('Native') }}</button>
-          <button class="muted" disabled :title="t('coming 
next')">Zipkin</button>
+          <button :class="{ on: traceSource === 'zipkin' }" 
@click="traceSource = 'zipkin'">Zipkin</button>
         </div>
       </div>
 
@@ -375,16 +509,33 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
           <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">
+              <div v-if="!isZipkin" 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">
+              <button v-if="!isZipkin && entityMode === 'pick'" 
class="iq-link" :disabled="!pickServiceId" @click="seedTypeFromPick">
                 {{ t('→ edit as text') }}
               </button>
             </div>
 
-            <div class="iq-grid" v-if="entityMode === 'pick'">
+            <!-- Zipkin entity — a raw service universe (no layer / no id).
+                 Remote service + span are service-scoped autocompletes. -->
+            <div class="iq-grid" v-if="isZipkin">
+              <label class="cf">
+                <span>{{ t('Service') }}</span>
+                <TypeaheadSelect v-model="zipkinService" 
:aria-label="t('Service')" :options="zipkinServiceOptions" :placeholder="t('All 
services')" class="cf-tas" />
+              </label>
+              <label class="cf" :class="{ 'cf-disabled': !zipkinHasService }">
+                <span>{{ t('Remote service') }} <small 
v-if="!zipkinHasService" class="dim">— {{ t('pick a service') }}</small></span>
+                <TypeaheadSelect v-model="zipkinRemote" :aria-label="t('Remote 
service')" :options="zipkinRemoteOptions" :disabled="!zipkinHasService" 
:placeholder="zipkinHasService ? t('any') : t('select a service first')" 
class="cf-tas" />
+              </label>
+              <label class="cf" :class="{ 'cf-disabled': !zipkinHasService }">
+                <span>{{ t('Span name') }} <small v-if="!zipkinHasService" 
class="dim">— {{ t('pick a service') }}</small></span>
+                <TypeaheadSelect v-model="zipkinSpan" :aria-label="t('Span 
name')" :options="zipkinSpanOptions" :disabled="!zipkinHasService" 
:placeholder="zipkinHasService ? t('any') : t('select a service first')" 
class="cf-tas" />
+              </label>
+            </div>
+
+            <div class="iq-grid" v-else-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" />
@@ -442,7 +593,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
                 <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">
+              <label v-if="!isZipkin" class="cf">
                 <span>{{ t('Status') }}</span>
                 <select v-model="cond.traceState" class="cf-input">
                   <option value="ALL">{{ t('All') }}</option>
@@ -450,7 +601,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
                   <option value="ERROR">{{ t('Error') }}</option>
                 </select>
               </label>
-              <label class="cf">
+              <label v-if="!isZipkin" class="cf">
                 <span>{{ t('Order') }}</span>
                 <select v-model="cond.queryOrder" class="cf-input">
                   <option value="BY_START_TIME">{{ t('Newest') }}</option>
@@ -465,10 +616,14 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
                   <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">
+              <label v-if="!isZipkin" 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 v-else class="cf cf-wide">
+                <span>{{ t('Annotation query') }}</span>
+                <input v-model="cond.annotationQuery" class="cf-input mono" 
type="text" :placeholder="t('error or key=value, AND-joined')" />
+              </label>
               <label class="cf" :class="{ 'cf-wide': isCustomRange }">
                 <span>{{ t('Time') }}</span>
                 <template v-if="isCustomRange">
@@ -554,19 +709,30 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
             @toggle-rail="railOpen = !railOpen"
           />
           <div class="iq-detail">
-            <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
+            <ZipkinTraceDetailCard
+              v-if="isZipkin"
               ref="detailCard"
-              :spans="waterfallSpans"
+              :spans="zipkinSpans"
               :trace-id="selectedTraceId"
-              :trace-ids="selectedTraceIds"
-              :loading="detailFetching"
+              :loading="detailLoading"
               @close="closeDetail"
-              @change-trace-id="changeSelectedTraceId"
               @update:modal-open="spanModalOpen = $event"
             />
+            <template v-else>
+              <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
+                ref="detailCard"
+                :spans="waterfallSpans"
+                :trace-id="selectedTraceId"
+                :trace-ids="selectedTraceIds"
+                :loading="detailFetching"
+                @close="closeDetail"
+                @change-trace-id="changeSelectedTraceId"
+                @update:modal-open="spanModalOpen = $event"
+              />
+            </template>
           </div>
         </section>
       </div>
@@ -592,7 +758,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
 .seg.sm button { padding: 3px 10px; }
 .seg button + button { border-left: 1px solid var(--sw-line-2); }
 .seg button.on { background: var(--sw-accent); color: #fff; }
-.seg button.muted { opacity: 0.45; cursor: not-allowed; }
 .iq-target { border: 1px solid var(--sw-line); border-radius: 6px; padding: 
8px 10px; }
 .iq-target-h { display: flex; align-items: center; gap: 12px; margin-bottom: 
8px; font-size: 11px; color: var(--sw-fg-2); font-weight: 600; }
 .iq-target-h .dim { color: var(--sw-fg-4); font-weight: 400; }
@@ -604,6 +769,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onPageKeyDown, true)
 .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; }
+.cf.cf-disabled > span { color: var(--sw-fg-3); opacity: 0.7; }
+.cf small { font-weight: 400; font-size: 9.5px; margin-left: 4px; font-style: 
italic; }
 .iq-chk { display: inline-flex; align-items: center; gap: 6px; height: 28px; }
 .iq-chk .dim { color: var(--sw-fg-4); }
 .cf-input {
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 9d9b0d3..333e395 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "Min. Dauer (ms)",
   "Max duration (ms)": "Max. Dauer (ms)",
   "Annotations": "Annotationen",
+  "Annotation query": "Annotationsabfrage",
+  "All services": "Alle Services",
   "error or key=value, AND-joined": "error oder key=value, UND-verknüpft",
   "Open trace ID": "Trace-ID öffnen",
   "paste trace id…": "Trace-ID einfügen…",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 0b06fa3..b34fa73 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -426,6 +426,8 @@
   "Min duration (ms)": "Min duration (ms)",
   "Max duration (ms)": "Max duration (ms)",
   "Annotations": "Annotations",
+  "Annotation query": "Annotation query",
+  "All services": "All services",
   "error or key=value, AND-joined": "error or key=value, AND-joined",
   "Open trace ID": "Open trace ID",
   "paste trace id…": "paste trace id…",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 3b5fcb2..6266fe0 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "Duración mínima (ms)",
   "Max duration (ms)": "Duración máxima (ms)",
   "Annotations": "Anotaciones",
+  "Annotation query": "Consulta de anotaciones",
+  "All services": "Todos los servicios",
   "error or key=value, AND-joined": "error o clave=valor, unidos con AND",
   "Open trace ID": "Abrir ID de traza",
   "paste trace id…": "pega un ID de traza…",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 2617b2f..218d6e8 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "Durée min (ms)",
   "Max duration (ms)": "Durée max (ms)",
   "Annotations": "Annotations",
+  "Annotation query": "Requête d'annotation",
+  "All services": "Tous les services",
   "error or key=value, AND-joined": "error ou clé=valeur, joints par AND",
   "Open trace ID": "Ouvrir l'ID de trace",
   "paste trace id…": "collez l'id de trace…",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index eb2d482..4daa0ca 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "最小所要時間(ms)",
   "Max duration (ms)": "最大所要時間(ms)",
   "Annotations": "アノテーション",
+  "Annotation query": "アノテーションクエリ",
+  "All services": "すべてのサービス",
   "error or key=value, AND-joined": "error または key=value(AND 結合)",
   "Open trace ID": "トレース ID を開く",
   "paste trace id…": "トレース ID を貼り付け…",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 7504748..1c04fa7 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "최소 기간 (ms)",
   "Max duration (ms)": "최대 기간 (ms)",
   "Annotations": "주석",
+  "Annotation query": "주석 쿼리",
+  "All services": "모든 서비스",
   "error or key=value, AND-joined": "error 또는 key=value, AND로 결합",
   "Open trace ID": "트레이스 ID 열기",
   "paste trace id…": "트레이스 ID를 붙여넣으세요…",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index f781bfe..97e0ce3 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "Duração mínima (ms)",
   "Max duration (ms)": "Duração máxima (ms)",
   "Annotations": "Anotações",
+  "Annotation query": "Consulta de anotações",
+  "All services": "Todos os serviços",
   "error or key=value, AND-joined": "error ou chave=valor, unidos por AND",
   "Open trace ID": "Abrir trace ID",
   "paste trace id…": "cole o trace id…",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index ae53d88..e98e124 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -456,6 +456,8 @@
   "Min duration (ms)": "最小耗时(ms)",
   "Max duration (ms)": "最大耗时(ms)",
   "Annotations": "注解",
+  "Annotation query": "注解查询",
+  "All services": "全部服务",
   "error or key=value, AND-joined": "error 或 key=value,使用 AND 连接",
   "Open trace ID": "打开 Trace ID",
   "paste trace id…": "粘贴 trace id…",
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue 
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index d027a9b..79b5fb4 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -32,6 +32,7 @@ import { useLayerZipkinTraces, useZipkinTrace } from 
'@/layer/traces/useZipkinTr
 import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
 import { bffClient } from '@/api/client';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import ZipkinTraceDetailCard from '@/render/widgets/ZipkinTraceDetailCard.vue';
 
 // Zipkin trace data is keyed by its own service universe (the names
 // reported in span `localEndpoint.serviceName`), not by SkyWalking's
@@ -291,16 +292,6 @@ function selectRow(row: ZipkinTraceListRow): void {
 function closeDetail(): void {
   selectedTraceId.value = null;
 }
-function copyDetailTraceId(): void {
-  if (!selectedTraceId.value) return;
-  navigator.clipboard?.writeText(selectedTraceId.value).catch(() => {});
-}
-function copyDetailUrl(): void {
-  if (!selectedTraceId.value || typeof window === 'undefined') return;
-  const url = new URL(window.location.href);
-  url.searchParams.set('traceId', selectedTraceId.value);
-  navigator.clipboard?.writeText(url.toString()).catch(() => {});
-}
 /** Rail toggle — when a trace is selected, the result list collapses
  *  to a narrow rail with progress-bar-only entries (folder style).
  *  Click any bar to swap the inline detail without expanding back. */
@@ -311,160 +302,29 @@ watch([cService, cLookback, cLimit, cSpan, cRemote, 
cAnno], () => {
   selectedTraceId.value = null;
 });
 
-// ── Inline span tree for the selected trace ─────────────────────
-// Fetches the full Zipkin span set + flattens into a depth-indented
-// waterfall row list. Reused-but-simplified from `ZipkinTracePopout`:
-// same parent-id walk, fewer per-row affordances since the rail is
-// narrower than the popout.
+// Full Zipkin span set for the selected trace — rendered by the shared
+// ZipkinTraceDetailCard (waterfall + KPIs + span modal).
 const { spans: selectedSpans, isLoading: selectedLoading } = 
useZipkinTrace(selectedTraceId);
-interface DetailRow { span: 
import('@skywalking-horizon-ui/api-client').ZipkinSpan; depth: number; 
offsetUs: number; durUs: number; }
-const detailBounds = computed(() => {
-  let t0 = Infinity;
-  let t1 = -Infinity;
-  for (const s of selectedSpans.value) {
-    const start = s.timestamp ?? 0;
-    const dur = s.duration ?? 0;
-    if (start && start < t0) t0 = start;
-    if (start && (start + dur) > t1) t1 = start + dur;
-  }
-  if (!Number.isFinite(t0)) t0 = 0;
-  if (!Number.isFinite(t1) || t1 <= t0) t1 = t0 + 1;
-  return { t0, t1, totalUs: t1 - t0 };
-});
-const detailRows = computed<DetailRow[]>(() => {
-  const list = selectedSpans.value;
-  if (list.length === 0) return [];
-  const byParent = new Map<string, typeof list>();
-  for (const s of list) {
-    const p = s.parentId ?? '';
-    if (!byParent.has(p)) byParent.set(p, [] as typeof list);
-    (byParent.get(p) as typeof list).push(s);
-  }
-  for (const arr of byParent.values()) arr.sort((a, b) => (a.timestamp ?? 0) - 
(b.timestamp ?? 0));
-  const out: DetailRow[] = [];
-  const { t0 } = detailBounds.value;
-  const visited = new Set<string>();
-  function walk(parentId: string, depth: number): void {
-    for (const s of (byParent.get(parentId) ?? [])) {
-      if (visited.has(s.id)) continue; // guard against cyclic parentId chains
-      visited.add(s.id);
-      const start = s.timestamp ?? t0;
-      out.push({ span: s, depth, offsetUs: start - t0, durUs: s.duration ?? 0 
});
-      walk(s.id, depth + 1);
-    }
-  }
-  // Real roots have no parent or a parent missing from the set.
-  const ids = new Set(list.map((s) => s.id));
-  walk('', 0);
-  for (const s of list) {
-    if (!s.parentId) continue;
-    if (ids.has(s.parentId)) continue;
-    const start = s.timestamp ?? t0;
-    out.push({ span: s, depth: 0, offsetUs: start - t0, durUs: s.duration ?? 0 
});
-    walk(s.id, 1);
-  }
-  return out;
-});
-// Palette + hash match the native trace detail (TracePopout).
-const SERVICE_PALETTE = [
-  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
-  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
-  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
-];
-function hashString(s: string): number {
-  let h = 2166136261;
-  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 
16777619); }
-  return h >>> 0;
-}
-function detailColor(name: string | null | undefined): string {
-  if (!name) return 'var(--sw-fg-2)';
-  return SERVICE_PALETTE[hashString(name) % SERVICE_PALETTE.length]!;
-}
-const detailServiceColors = computed<Map<string, string>>(() => {
-  const m = new Map<string, string>();
-  for (const s of selectedSpans.value) {
-    const n = s.localEndpoint?.serviceName ?? '—';
-    if (!m.has(n)) m.set(n, detailColor(n));
-  }
-  return m;
-});
-const detailRootStart = computed<number | null>(() => {
-  const ts = selectedSpans.value.map((s) => s.timestamp ?? 0).filter(Boolean);
-  return ts.length ? Math.min(...ts) : null;
-});
-function kindColor(k: string | null | undefined): string {
-  const t = (k ?? '').toUpperCase();
-  if (t.includes('SERVER')) return 'var(--sw-accent)';
-  if (t.includes('CLIENT')) return 'var(--sw-info)';
-  if (t.includes('PRODUCER') || t.includes('CONSUMER')) return 
'var(--sw-purple)';
-  return 'var(--sw-fg-2)';
-}
-// Zipkin/B3 timing codes → human label; unknown values render raw.
-const ANNOTATION_LABELS: Record<string, string> = {
-  cs: 'client send', cr: 'client receive',
-  sr: 'server receive', ss: 'server send',
-  ws: 'wire send', wr: 'wire receive',
-  error: 'error',
-};
-function annotationHint(value: string): string | null {
-  const label = ANNOTATION_LABELS[value.trim().toLowerCase()];
-  return label ? t(label) : null;
-}
-function fmtDateTime(usSinceEpoch: number | null | undefined): string {
-  if (!usSinceEpoch || !Number.isFinite(usSinceEpoch)) return '—';
-  const d = new Date(usSinceEpoch / 1000);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
-}
-function detailLeftPct(us: number): number {
-  return Math.max(0, Math.min(100, (us / (detailBounds.value.totalUs || 1)) * 
100));
-}
-function detailWidthPct(us: number): number {
-  if (us <= 0) return 0.8;
-  return Math.max(0.8, Math.min(100, (us / (detailBounds.value.totalUs || 1)) 
* 100));
-}
-
-// ── Span detail (Lens-style inline panel) ───────────────────────
-// Clicking a waterfall row pins that span. The panel mirrors Zipkin
-// Lens's span sidebar: service header, key-value identity block,
-// tag table, annotation timeline. Selection is local to the inline
-// view — the popout has its own.
-const selectedSpanId = ref<string | null>(null);
-const selectedSpan = 
computed<import('@skywalking-horizon-ui/api-client').ZipkinSpan | null>(() => {
-  if (!selectedSpanId.value) return null;
-  return selectedSpans.value.find((s) => s.id === selectedSpanId.value) ?? 
null;
-});
-function selectSpan(s: 
import('@skywalking-horizon-ui/api-client').ZipkinSpan): void {
-  selectedSpanId.value = selectedSpanId.value === s.id ? null : s.id;
-}
-function clearSpan(): void {
-  selectedSpanId.value = null;
-}
-// Clear the pinned span when the operator switches trace.
-watch(selectedTraceId, () => { selectedSpanId.value = null; });
 
-// Dismiss the floating span-detail overlay on Escape or click-outside.
-const spanDetailRef = ref<HTMLElement | null>(null);
-function onSpanDetailDocClick(e: MouseEvent): void {
-  if (!selectedSpan.value) return;
-  const t = e.target as Element | null;
-  if (!t) return;
-  if (spanDetailRef.value?.contains(t)) return;
-  clearSpan();
-}
-function onSpanDetailKey(e: KeyboardEvent): void {
-  if (e.key === 'Escape' && selectedSpan.value) {
+// Esc cascade: the detail card owns the span modal — close it first
+// (via the card's `closeSpanModal`), then the inline detail.
+const detailCard = ref<{ closeSpanModal: () => void } | 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();
-    clearSpan();
   }
 }
-onMounted(() => {
-  document.addEventListener('mousedown', onSpanDetailDocClick);
-  document.addEventListener('keydown', onSpanDetailKey);
-});
+onMounted(() => window.addEventListener('keydown', onPageKeyDown, true));
 onBeforeUnmount(() => {
-  document.removeEventListener('mousedown', onSpanDetailDocClick);
-  document.removeEventListener('keydown', onSpanDetailKey);
+  window.removeEventListener('keydown', onPageKeyDown, true);
   if (autocompleteTimer) clearTimeout(autocompleteTimer);
 });
 
@@ -960,139 +820,14 @@ function pickScatterDot(p: ScatterPoint, ev: 
MouseEvent): void {
         </ul>
       </aside>
 
-      <article class="ztr-detail sw-card">
-        <header class="ztr-detail-head">
-          <span class="kicker">{{ t('Trace') }}</span>
-          <code class="ztr-tid mono" :title="selectedTraceId ?? ''">{{ 
selectedTraceId }}</code>
-          <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
trace id')" @click="copyDetailTraceId">⧉ {{ t('id') }}</button>
-          <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
shareable URL')" @click="copyDetailUrl">⧉ {{ t('url') }}</button>
-          <span v-if="selectedLoading" class="hint">{{ t('loading…') }}</span>
-          <button class="sw-btn small ghost ztr-detail-close" type="button" 
:title="t('Close')" @click="closeDetail">×</button>
-        </header>
-        <div v-if="selectedLoading" class="ztr-empty hint">{{ t('loading 
spans…') }}</div>
-        <div v-else-if="detailRows.length === 0" class="ztr-empty">{{ t('No 
spans for this trace.') }}</div>
-        <template v-else>
-          <div class="ztr-kpis">
-            <div><div class="kpi-label">{{ t('started') }}</div><div 
class="kpi-val">{{ fmtDateTime(detailRootStart) }}</div></div>
-            <div><div class="kpi-label">{{ t('duration') }}</div><div 
class="kpi-val">{{ fmtMs(detailBounds.totalUs) }}</div></div>
-            <div><div class="kpi-label">{{ t('spans') }}</div><div 
class="kpi-val">{{ detailRows.length }}</div></div>
-            <div><div class="kpi-label">{{ t('services') }}</div><div 
class="kpi-val">{{ detailServiceColors.size }}</div></div>
-          </div>
-          <div v-if="detailServiceColors.size > 0" class="ztr-svc-legend">
-            <span v-for="[code, color] in detailServiceColors" :key="code" 
class="svc-chip">
-              <span class="svc-swatch" :style="{ background: color }" />
-              <span class="mono">{{ code }}</span>
-            </span>
-          </div>
-          <div class="ztr-detail-body">
-            <div class="ztr-waterfall">
-              <div class="tp-time-axis">
-                <span class="t-tick first">0</span>
-                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.25) 
}}</span>
-                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.5) 
}}</span>
-                <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.75) 
}}</span>
-                <span class="t-tick last">{{ fmtMs(detailBounds.totalUs) 
}}</span>
-              </div>
-              <div
-                v-for="row in detailRows"
-                :key="row.span.id"
-                class="tp-row"
-                :class="{ err: row.span.tags?.error != null, on: 
selectedSpanId === row.span.id }"
-                @click="selectSpan(row.span)"
-              >
-                <div class="tp-track">
-                  <span class="t-grid q1" /><span class="t-grid q2" /><span 
class="t-grid q3" />
-                  <div
-                    class="tp-bar"
-                    :style="{
-                      left: detailLeftPct(row.offsetUs) + '%',
-                      width: detailWidthPct(row.durUs) + '%',
-                      background: 
detailColor(row.span.localEndpoint?.serviceName),
-                      borderColor: row.span.tags?.error != null ? 
'var(--sw-err)' : 'transparent',
-                    }"
-                  >
-                    <span class="bar-inner">
-                      <span
-                        class="status-flag sm"
-                        :class="row.span.tags?.error != null ? 'flag-err' : 
'flag-ok'"
-                        :title="row.span.tags?.error != null ? t('Span 
errored') : t('Span OK')"
-                      ><span class="flag-dot" /></span>
-                      <svg class="comp-icon comp-icon-generic" viewBox="0 0 18 
18" :aria-label="t('generic span')">
-                        <rect x="3" y="4.5" width="12" height="3" rx="1.5" 
fill="currentColor" opacity="0.45" />
-                        <rect x="5" y="10.5" width="10" height="3" rx="1.5" 
fill="currentColor" opacity="0.85" />
-                      </svg>
-                      <span class="bar-text mono">
-                        <span class="bar-svc" 
:title="row.span.localEndpoint?.serviceName ?? ''">{{ 
row.span.localEndpoint?.serviceName ?? '—' }}</span>
-                        <span class="bar-name" :title="row.span.name || 
row.span.remoteEndpoint?.serviceName || '—'">{{ row.span.name || 
row.span.remoteEndpoint?.serviceName || '—' }}</span>
-                      </span>
-                      <span v-if="detailWidthPct(row.durUs) > 12" 
class="bar-dur-inside mono">{{ fmtMs(row.durUs) }}</span>
-                    </span>
-                  </div>
-                  <span
-                    v-if="detailWidthPct(row.durUs) <= 12"
-                    class="bar-dur-outside mono"
-                    :style="{ left: `calc(${detailLeftPct(row.offsetUs + 
row.durUs)}% + 6px)` }"
-                  >{{ fmtMs(row.durUs) }}</span>
-                </div>
-              </div>
-            </div>
-
-          <!-- Span detail opens as a centered modal (max-width 920px) so
-               it can render long tag values without compressing the
-               waterfall bars. Click ×, click the same span, or pick
-               another span to dismiss / swap. -->
-          <div v-if="selectedSpan" class="span-modal-backdrop">
-            <article ref="spanDetailRef" class="span-modal sw-card">
-            <header class="span-modal-head">
-              <h4><span class="dim">{{ t('Span detail') }}</span> <span 
class="mono">{{ selectedSpan.name || '—' }}</span></h4>
-              <button class="sw-btn small ghost" type="button" 
:title="t('Close')" @click="clearSpan">×</button>
-            </header>
-            <div class="span-modal-body">
-            <section class="sd-section">
-              <h6>{{ t('Meta') }}</h6>
-              <dl class="kv">
-                <dt>{{ t('Service') }}</dt>
-                <dd class="mono" :style="{ color: 
detailColor(selectedSpan.localEndpoint?.serviceName) }">
-                  <span class="svc-swatch inline" :style="{ background: 
detailColor(selectedSpan.localEndpoint?.serviceName) }" />
-                  {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
-                </dd>
-                <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ 
selectedSpan.name || '—' }}</dd>
-                <dt>{{ t('Kind') }}</dt><dd><span class="tp-kind" :style="{ 
color: kindColor(selectedSpan.kind) }">{{ selectedSpan.kind ?? '—' 
}}</span></dd>
-                <dt>{{ t('Peer') }}</dt><dd class="mono wba">{{ 
selectedSpan.remoteEndpoint?.serviceName || '—' }}</dd>
-                <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6">{{ t('Peer addr') }}</dt>
-                <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6" class="mono wba">{{ 
selectedSpan.remoteEndpoint?.ipv4 ?? selectedSpan.remoteEndpoint?.ipv6 
}}<template v-if="selectedSpan.remoteEndpoint?.port">:{{ 
selectedSpan.remoteEndpoint.port }}</template></dd>
-                <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ 
selectedSpan.id }}</dd>
-                <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
-                <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
-                <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtDateTime(selectedSpan.timestamp) }}</dd>
-                <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
-                <dt>{{ t('Error') }}</dt><dd><span class="status-flag" 
:class="selectedSpan.tags?.error != null ? 'flag-err' : 'flag-ok'"><span 
class="flag-dot" />{{ selectedSpan.tags?.error != null ? t('true') : t('false') 
}}</span></dd>
-              </dl>
-            </section>
-            <section v-if="selectedSpan.tags && 
Object.keys(selectedSpan.tags).length > 0" class="sd-section">
-              <h6>{{ t('Tags') }}</h6>
-              <dl class="kv">
-                <template v-for="(v, k) in selectedSpan.tags" :key="k">
-                  <dt class="mono">{{ k }}</dt>
-                  <dd class="mono wba" :class="{ err: k === 'error' }">{{ v 
}}</dd>
-                </template>
-              </dl>
-            </section>
-            <section v-if="selectedSpan.annotations && 
selectedSpan.annotations.length > 0" class="sd-section">
-              <h6>{{ t('Annotations') }}</h6>
-              <div v-for="(a, i) in selectedSpan.annotations" :key="i" 
class="span-event">
-                <div class="span-event-head">
-                  <span class="mono">{{ a.value }}<span 
v-if="annotationHint(a.value)" class="ann-hint" :title="annotationHint(a.value) 
?? ''"> ({{ annotationHint(a.value) }})</span></span>
-                  <span class="dim mono">{{ fmtDateTime(a.timestamp) }}</span>
-                </div>
-              </div>
-            </section>
-            </div>
-            </article>
-          </div>
-          </div>
-        </template>
-      </article>
+      <ZipkinTraceDetailCard
+        ref="detailCard"
+        :spans="selectedSpans"
+        :trace-id="selectedTraceId"
+        :loading="selectedLoading"
+        @close="closeDetail"
+        @update:modal-open="spanModalOpen = $event"
+      />
     </section>
   </div>
 </template>
@@ -1374,282 +1109,7 @@ function pickScatterDot(p: ScatterPoint, ev: 
MouseEvent): void {
   grid-template-columns: 1fr;
   gap: 12px;
 }
-.ztr-detail {
-  display: flex;
-  flex-direction: column;
-  /* Match the rail's sticky viewport-anchored height so the two cards
-     read as equal-height side-by-side. Without this they sized to
-     content independently (rail was viewport-tall, detail capped at
-     720px) and looked visually mismatched. */
-  position: sticky;
-  top: 12px;
-  max-height: calc(100vh - 80px);
-  min-height: 240px;
-  overflow: hidden;
-}
-.ztr-detail-head {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
-}
-.ztr-tid { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: 
ellipsis; white-space: nowrap; color: var(--sw-fg-2); font-size: 11px; }
-.ztr-detail-close { margin-left: auto; }
-
-.ztr-detail-body {
-  flex: 1;
-  position: relative;
-  display: flex;
-  min-height: 0;
-  overflow: hidden;
-}
-.span-modal-backdrop {
-  position: fixed;
-  inset: 0;
-  background: rgba(0, 0, 0, 0.6);
-  z-index: 1000;
-  display: flex;
-  align-items: flex-start;
-  justify-content: center;
-  padding: 40px 20px;
-  overflow-y: auto;
-}
-.span-modal {
-  width: 100%;
-  max-width: 920px;
-  max-height: calc(100vh - 80px);
-  display: flex;
-  flex-direction: column;
-}
-.span-modal-head {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
-  flex: 0 0 auto;
-}
-.span-modal-head h4 {
-  margin: 0;
-  font-size: 12px;
-  font-weight: 600;
-  display: inline-flex;
-  gap: 10px;
-  align-items: baseline;
-  flex: 1;
-  min-width: 0;
-}
-.span-modal-head h4 .dim { color: var(--sw-fg-3); font-weight: 500; }
-.span-modal-head h4 .mono {
-  font-family: var(--sw-mono);
-  color: var(--sw-fg-1);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.span-modal-body { padding: 12px 14px 16px; overflow-y: auto; }
-.sd-section { margin-bottom: 14px; }
-.sd-section h6 {
-  margin: 0 0 6px;
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.08em;
-  color: var(--sw-accent);
-  font-weight: 700;
-}
-.kv {
-  display: grid;
-  grid-template-columns: 100px 1fr;
-  gap: 4px 10px;
-  margin: 0;
-  font-size: 11px;
-}
-.kv dt { color: var(--sw-fg-3); font-size: 10.5px; min-width: 0; 
overflow-wrap: anywhere; }
-.kv dd { margin: 0; color: var(--sw-fg-1); min-width: 0; }
-.kv dd.wba { word-break: break-all; }
-.kv dd.err { color: var(--sw-err); }
-.tp-kind { font-family: var(--sw-mono); font-size: 11px; font-weight: 600; }
-.span-event {
-  border: 1px solid var(--sw-line);
-  border-radius: 4px;
-  padding: 6px 10px;
-  margin-bottom: 6px;
-  background: var(--sw-bg-0);
-}
-.span-event-head {
-  display: flex;
-  justify-content: space-between;
-  gap: 10px;
-  font-size: 11px;
-}
-.ann-hint { color: var(--sw-fg-3); }
 .dim { color: var(--sw-fg-3); }
-.wba { word-break: break-all; }
-
-/* Inline waterfall — each span is a row with `<label> <track> <dur>`.
-   Track width is dynamic; the bar inside left/width % positions the
-   span chronologically within the trace's window. Service color
-   matches the popout palette so the two views read consistently. */
-.ztr-waterfall {
-  flex: 1 1 0;
-  min-width: 0;
-  /* overflow-y:auto forces x:auto; clip x (full-width bar's duration renders 
inside). */
-  overflow-x: hidden;
-  overflow-y: auto;
-  padding: 4px 0;
-}
-.ztr-kpis {
-  display: flex;
-  gap: 24px;
-  padding: 8px 14px;
-  border-bottom: 1px solid var(--sw-line);
-  flex: 0 0 auto;
-}
-.kpi-label {
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  color: var(--sw-fg-3);
-}
-.kpi-val {
-  font-family: var(--sw-mono);
-  font-size: 13px;
-  color: var(--sw-fg-0);
-  font-weight: 700;
-}
-.ztr-svc-legend {
-  display: flex;
-  gap: 6px;
-  flex-wrap: wrap;
-  padding: 6px 14px;
-  border-bottom: 1px solid var(--sw-line);
-  background: var(--sw-bg-1);
-  flex: 0 0 auto;
-}
-.svc-chip {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-  font-size: 10.5px;
-  color: var(--sw-fg-2);
-  padding: 1px 6px 1px 4px;
-  border: 1px solid var(--sw-line-2);
-  border-radius: 10px;
-  background: var(--sw-bg-2);
-}
-.svc-swatch { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
-.svc-swatch.inline { display: inline-block; margin-right: 4px; vertical-align: 
middle; }
-.tp-time-axis {
-  position: sticky;
-  top: 0;
-  z-index: 10;
-  display: flex;
-  justify-content: space-between;
-  padding: 6px 12px;
-  background-color: var(--sw-bg-1);
-  border-bottom: 1px solid var(--sw-line);
-  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
-  font-family: var(--sw-mono);
-  font-size: 10px;
-  color: var(--sw-fg-3);
-}
-.t-tick { white-space: nowrap; }
-.t-tick.first { text-align: left; }
-.t-tick.last { text-align: right; }
-.tp-row { padding: 3px 12px; cursor: pointer; }
-.tp-row:hover { background: var(--sw-bg-2); }
-.tp-row.err { background: rgba(239, 68, 68, 0.06); }
-.tp-row.on { background: var(--sw-accent-soft); }
-.tp-track { position: relative; height: 22px; background: transparent; }
-.t-grid {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  width: 1px;
-  background: var(--sw-line);
-  opacity: 0.55;
-  pointer-events: none;
-}
-.t-grid.q1 { left: 25%; }
-.t-grid.q2 { left: 50%; }
-.t-grid.q3 { left: 75%; }
-.tp-bar {
-  position: absolute;
-  top: 2px;
-  bottom: 2px;
-  border-radius: 3px;
-  border: 1px solid transparent;
-  display: flex;
-  align-items: center;
-  overflow: hidden;
-  cursor: pointer;
-  transition: filter 0.12s ease;
-}
-.tp-bar:hover { filter: brightness(1.12); }
-.bar-inner {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 0 6px;
-  height: 100%;
-  width: 100%;
-  min-width: 0;
-}
-.bar-text {
-  display: inline-flex;
-  align-items: baseline;
-  gap: 6px;
-  min-width: 0;
-  flex: 1 1 auto;
-  overflow: hidden;
-  color: var(--sw-bg-0);
-  text-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
-}
-.bar-svc {
-  font-weight: 700;
-  font-size: 10.5px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  flex: 0 1 auto;
-  max-width: 40%;
-  opacity: 0.9;
-}
-.bar-name {
-  flex: 1 1 auto;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  font-size: 11px;
-  font-weight: 500;
-}
-.comp-icon { width: 18px; height: 18px; flex: 0 0 auto; object-fit: contain; 
background: transparent; }
-.comp-icon-generic { color: var(--sw-fg-2); }
-.bar-inner .comp-icon-generic { color: rgba(0, 0, 0, 0.72); }
-.bar-inner .status-flag.sm { background: rgba(255, 255, 255, 0.35); color: 
var(--sw-bg-0); }
-.bar-inner .status-flag.flag-err { background: var(--sw-err); color: #fff; }
-.bar-dur-outside {
-  position: absolute;
-  top: 50%;
-  transform: translateY(-50%);
-  font-size: 10px;
-  color: var(--sw-fg-2);
-  white-space: nowrap;
-  pointer-events: none;
-}
-.bar-dur-inside {
-  flex: 0 0 auto;
-  margin-left: auto;
-  padding-left: 8px;
-  font-size: 10px;
-  color: var(--sw-bg-0);
-  opacity: 0.82;
-  white-space: nowrap;
-}
-.status-flag.sm { height: 12px; width: 12px; padding: 0; border-radius: 50%; 
justify-content: center; }
-.status-flag.sm .flag-dot { margin: 0; }
 
 @media (max-width: 1100px) {
   /* On narrow screens, fall back to stacked layout — the detail rail
@@ -1770,8 +1230,7 @@ function pickScatterDot(p: ScatterPoint, ev: MouseEvent): 
void {
 }
 .rail-ep.red { color: var(--sw-err); }
 
-/* Detail-head buttons + small-text affordance. */
-.small { font-size: 10.5px; }
+/* Small buttons (scatter Reset). */
 .sw-btn.small {
   height: 22px;
   padding: 0 8px;
@@ -1792,5 +1251,4 @@ function pickScatterDot(p: ScatterPoint, ev: MouseEvent): 
void {
   font-size: 14px;
 }
 .sw-btn.small.ghost:hover { color: var(--sw-err); background: var(--sw-bg-2); }
-.sw-btn.small.copy-btn { background: transparent; color: var(--sw-fg-2); 
white-space: nowrap; }
 </style>
diff --git a/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue 
b/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue
new file mode 100644
index 0000000..12babf8
--- /dev/null
+++ b/apps/ui/src/render/widgets/ZipkinTraceDetailCard.vue
@@ -0,0 +1,662 @@
+<!--
+  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 Zipkin trace-detail card. Given a flat ZipkinSpan[] it renders
+  the parent-id-walked waterfall (depth-indented), KPIs, the per-service
+  colour legend, and a span-detail modal — mirroring TraceDetailCard's
+  chrome but for the Zipkin v2 span shape. Used by BOTH the per-layer
+  Traces · Zipkin tab and the cross-layer Trace inspect view.
+
+  Props:
+    spans    — the flat ZipkinSpan[] of the resolved trace.
+    traceId  — the active trace id (header label + copy).
+    loading  — show a "loading…" hint while detail fetches.
+
+  Emits:
+    close             — the × was clicked.
+    update:modalOpen  — the span-detail modal opened / closed; hosts use
+                        this to order their Esc cascade (modal first, then
+                        the inline detail).
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { ZipkinSpan } from '@/api/client';
+
+const { t } = useI18n({ useScope: 'global' });
+
+const props = withDefaults(
+  defineProps<{
+    spans: ZipkinSpan[];
+    traceId: string | null;
+    loading?: boolean;
+  }>(),
+  { loading: false },
+);
+const emit = defineEmits<{
+  (e: 'close'): void;
+  (e: 'update:modalOpen', open: boolean): void;
+}>();
+
+/** Public method so a host can close the span-detail modal as the first
+ *  step of its own Esc cascade. */
+function closeSpanModal(): void { selectedSpanId.value = null; }
+defineExpose({ closeSpanModal });
+
+function copyTraceId(): void {
+  if (!props.traceId) return;
+  navigator.clipboard?.writeText(props.traceId).catch(() => {});
+}
+function copyShareableUrl(): void {
+  if (!props.traceId || typeof window === 'undefined') return;
+  const url = new URL(window.location.href);
+  url.searchParams.set('traceId', props.traceId);
+  navigator.clipboard?.writeText(url.toString()).catch(() => {});
+}
+
+// ── Waterfall (parent-id walk → depth-indented rows) ─────────────
+interface DetailRow { span: ZipkinSpan; depth: number; offsetUs: number; 
durUs: number; }
+const detailBounds = computed(() => {
+  let t0 = Infinity;
+  let t1 = -Infinity;
+  for (const s of props.spans) {
+    const start = s.timestamp ?? 0;
+    const dur = s.duration ?? 0;
+    if (start && start < t0) t0 = start;
+    if (start && (start + dur) > t1) t1 = start + dur;
+  }
+  if (!Number.isFinite(t0)) t0 = 0;
+  if (!Number.isFinite(t1) || t1 <= t0) t1 = t0 + 1;
+  return { t0, t1, totalUs: t1 - t0 };
+});
+const detailRows = computed<DetailRow[]>(() => {
+  const list = props.spans;
+  if (list.length === 0) return [];
+  const byParent = new Map<string, ZipkinSpan[]>();
+  for (const s of list) {
+    const p = s.parentId ?? '';
+    if (!byParent.has(p)) byParent.set(p, []);
+    byParent.get(p)!.push(s);
+  }
+  for (const arr of byParent.values()) arr.sort((a, b) => (a.timestamp ?? 0) - 
(b.timestamp ?? 0));
+  const out: DetailRow[] = [];
+  const { t0 } = detailBounds.value;
+  const visited = new Set<string>();
+  function walk(parentId: string, depth: number): void {
+    for (const s of (byParent.get(parentId) ?? [])) {
+      if (visited.has(s.id)) continue; // guard against cyclic parentId chains
+      visited.add(s.id);
+      const start = s.timestamp ?? t0;
+      out.push({ span: s, depth, offsetUs: start - t0, durUs: s.duration ?? 0 
});
+      walk(s.id, depth + 1);
+    }
+  }
+  const ids = new Set(list.map((s) => s.id));
+  walk('', 0);
+  for (const s of list) {
+    if (!s.parentId) continue;
+    if (ids.has(s.parentId)) continue;
+    if (visited.has(s.id)) continue;
+    const start = s.timestamp ?? t0;
+    out.push({ span: s, depth: 0, offsetUs: start - t0, durUs: s.duration ?? 0 
});
+    walk(s.id, 1);
+  }
+  return out;
+});
+
+// ── Service colour palette (matches TraceDetailCard) ─────────────
+const SERVICE_PALETTE = [
+  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
+  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
+  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
+];
+function hashString(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 
16777619); }
+  return h >>> 0;
+}
+function detailColor(name: string | null | undefined): string {
+  if (!name) return 'var(--sw-fg-2)';
+  return SERVICE_PALETTE[hashString(name) % SERVICE_PALETTE.length]!;
+}
+const detailServiceColors = computed<Map<string, string>>(() => {
+  const m = new Map<string, string>();
+  for (const s of props.spans) {
+    const n = s.localEndpoint?.serviceName ?? '—';
+    if (!m.has(n)) m.set(n, detailColor(n));
+  }
+  return m;
+});
+const detailRootStart = computed<number | null>(() => {
+  const ts = props.spans.map((s) => s.timestamp ?? 0).filter(Boolean);
+  return ts.length ? Math.min(...ts) : null;
+});
+function kindColor(k: string | null | undefined): string {
+  const kind = (k ?? '').toUpperCase();
+  if (kind.includes('SERVER')) return 'var(--sw-accent)';
+  if (kind.includes('CLIENT')) return 'var(--sw-info)';
+  if (kind.includes('PRODUCER') || kind.includes('CONSUMER')) return 
'var(--sw-purple)';
+  return 'var(--sw-fg-2)';
+}
+// Zipkin/B3 timing codes → human label; unknown values render raw.
+const ANNOTATION_LABELS: Record<string, string> = {
+  cs: 'client send', cr: 'client receive',
+  sr: 'server receive', ss: 'server send',
+  ws: 'wire send', wr: 'wire receive',
+  error: 'error',
+};
+function annotationHint(value: string): string | null {
+  const label = ANNOTATION_LABELS[value.trim().toLowerCase()];
+  return label ? t(label) : null;
+}
+
+// ── Render helpers ───────────────────────────────────────────────
+function fmtMs(us: number | null | undefined): string {
+  if (us == null) return '—';
+  if (us < 1000) return `${us}μs`;
+  if (us < 1_000_000) return `${(us / 1000).toFixed(2)}ms`;
+  return `${(us / 1_000_000).toFixed(2)}s`;
+}
+function fmtDateTime(usSinceEpoch: number | null | undefined): string {
+  if (!usSinceEpoch || !Number.isFinite(usSinceEpoch)) return '—';
+  const d = new Date(usSinceEpoch / 1000);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+function detailLeftPct(us: number): number {
+  return Math.max(0, Math.min(100, (us / (detailBounds.value.totalUs || 1)) * 
100));
+}
+function detailWidthPct(us: number): number {
+  if (us <= 0) return 0.8;
+  return Math.max(0.8, Math.min(100, (us / (detailBounds.value.totalUs || 1)) 
* 100));
+}
+
+// ── Span detail modal ─────────────────────────────────────────────
+const selectedSpanId = ref<string | null>(null);
+const selectedSpan = computed<ZipkinSpan | null>(() => {
+  if (!selectedSpanId.value) return null;
+  return props.spans.find((s) => s.id === selectedSpanId.value) ?? null;
+});
+function selectSpan(s: ZipkinSpan): void {
+  selectedSpanId.value = selectedSpanId.value === s.id ? null : s.id;
+}
+function clearSpan(): void { selectedSpanId.value = null; }
+watch(() => props.traceId, () => { selectedSpanId.value = null; });
+watch(selectedSpan, (s) => emit('update:modalOpen', !!s));
+</script>
+
+<template>
+  <article class="ztr-detail sw-card">
+    <header class="ztr-detail-head">
+      <span class="kicker">{{ t('Trace') }}</span>
+      <code class="ztr-tid mono" :title="traceId ?? ''">{{ traceId }}</code>
+      <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
trace id')" @click="copyTraceId">⧉ {{ t('id') }}</button>
+      <button class="sw-btn small copy-btn" type="button" :title="t('Copy 
shareable URL')" @click="copyShareableUrl">⧉ {{ t('url') }}</button>
+      <span v-if="loading" class="hint">{{ t('loading…') }}</span>
+      <button class="sw-btn small ghost ztr-detail-close" type="button" 
:title="t('Close')" @click="emit('close')">×</button>
+    </header>
+    <div v-if="loading && detailRows.length === 0" class="ztr-empty hint">{{ 
t('loading spans…') }}</div>
+    <div v-else-if="detailRows.length === 0" class="ztr-empty">{{ t('No spans 
for this trace.') }}</div>
+    <template v-else>
+      <div class="ztr-kpis">
+        <div><div class="kpi-label">{{ t('started') }}</div><div 
class="kpi-val">{{ fmtDateTime(detailRootStart) }}</div></div>
+        <div><div class="kpi-label">{{ t('duration') }}</div><div 
class="kpi-val">{{ fmtMs(detailBounds.totalUs) }}</div></div>
+        <div><div class="kpi-label">{{ t('spans') }}</div><div 
class="kpi-val">{{ detailRows.length }}</div></div>
+        <div><div class="kpi-label">{{ t('services') }}</div><div 
class="kpi-val">{{ detailServiceColors.size }}</div></div>
+      </div>
+      <div v-if="detailServiceColors.size > 0" class="ztr-svc-legend">
+        <span v-for="[code, color] in detailServiceColors" :key="code" 
class="svc-chip">
+          <span class="svc-swatch" :style="{ background: color }" />
+          <span class="mono">{{ code }}</span>
+        </span>
+      </div>
+      <div class="ztr-detail-body">
+        <div class="ztr-waterfall">
+          <div class="tp-time-axis">
+            <span class="t-tick first">0</span>
+            <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.25) 
}}</span>
+            <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.5) }}</span>
+            <span class="t-tick">{{ fmtMs(detailBounds.totalUs * 0.75) 
}}</span>
+            <span class="t-tick last">{{ fmtMs(detailBounds.totalUs) }}</span>
+          </div>
+          <div
+            v-for="row in detailRows"
+            :key="row.span.id"
+            class="tp-row"
+            :class="{ err: row.span.tags?.error != null, on: selectedSpanId 
=== row.span.id }"
+            @click="selectSpan(row.span)"
+          >
+            <div class="tp-track">
+              <span class="t-grid q1" /><span class="t-grid q2" /><span 
class="t-grid q3" />
+              <div
+                class="tp-bar"
+                :style="{
+                  left: detailLeftPct(row.offsetUs) + '%',
+                  width: detailWidthPct(row.durUs) + '%',
+                  background: detailColor(row.span.localEndpoint?.serviceName),
+                  borderColor: row.span.tags?.error != null ? 'var(--sw-err)' 
: 'transparent',
+                }"
+              >
+                <span class="bar-inner">
+                  <span
+                    class="status-flag sm"
+                    :class="row.span.tags?.error != null ? 'flag-err' : 
'flag-ok'"
+                    :title="row.span.tags?.error != null ? t('Span errored') : 
t('Span OK')"
+                  ><span class="flag-dot" /></span>
+                  <svg class="comp-icon comp-icon-generic" viewBox="0 0 18 18" 
:aria-label="t('generic span')">
+                    <rect x="3" y="4.5" width="12" height="3" rx="1.5" 
fill="currentColor" opacity="0.45" />
+                    <rect x="5" y="10.5" width="10" height="3" rx="1.5" 
fill="currentColor" opacity="0.85" />
+                  </svg>
+                  <span class="bar-text mono">
+                    <span class="bar-svc" 
:title="row.span.localEndpoint?.serviceName ?? ''">{{ 
row.span.localEndpoint?.serviceName ?? '—' }}</span>
+                    <span class="bar-name" :title="row.span.name || 
row.span.remoteEndpoint?.serviceName || '—'">{{ row.span.name || 
row.span.remoteEndpoint?.serviceName || '—' }}</span>
+                  </span>
+                  <span v-if="detailWidthPct(row.durUs) > 12" 
class="bar-dur-inside mono">{{ fmtMs(row.durUs) }}</span>
+                </span>
+              </div>
+              <span
+                v-if="detailWidthPct(row.durUs) <= 12"
+                class="bar-dur-outside mono"
+                :style="{ left: `calc(${detailLeftPct(row.offsetUs + 
row.durUs)}% + 6px)` }"
+              >{{ fmtMs(row.durUs) }}</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- Span detail opens as a centered modal so it can render long
+             tag values without compressing the waterfall bars. -->
+        <div v-if="selectedSpan" class="span-modal-backdrop" 
@click.self="clearSpan">
+          <article class="span-modal sw-card">
+            <header class="span-modal-head">
+              <h4><span class="dim">{{ t('Span detail') }}</span> <span 
class="mono">{{ selectedSpan.name || '—' }}</span></h4>
+              <button class="sw-btn small ghost" type="button" 
:title="t('Close')" @click="clearSpan">×</button>
+            </header>
+            <div class="span-modal-body">
+              <section class="sd-section">
+                <h6>{{ t('Meta') }}</h6>
+                <dl class="kv">
+                  <dt>{{ t('Service') }}</dt>
+                  <dd class="mono" :style="{ color: 
detailColor(selectedSpan.localEndpoint?.serviceName) }">
+                    <span class="svc-swatch inline" :style="{ background: 
detailColor(selectedSpan.localEndpoint?.serviceName) }" />
+                    {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
+                  </dd>
+                  <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ 
selectedSpan.name || '—' }}</dd>
+                  <dt>{{ t('Kind') }}</dt><dd><span class="tp-kind" :style="{ 
color: kindColor(selectedSpan.kind) }">{{ selectedSpan.kind ?? '—' 
}}</span></dd>
+                  <dt>{{ t('Peer') }}</dt><dd class="mono wba">{{ 
selectedSpan.remoteEndpoint?.serviceName || '—' }}</dd>
+                  <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6">{{ t('Peer addr') }}</dt>
+                  <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || 
selectedSpan.remoteEndpoint?.ipv6" class="mono wba">{{ 
selectedSpan.remoteEndpoint?.ipv4 ?? selectedSpan.remoteEndpoint?.ipv6 
}}<template v-if="selectedSpan.remoteEndpoint?.port">:{{ 
selectedSpan.remoteEndpoint.port }}</template></dd>
+                  <dt>{{ t('Span id') }}</dt><dd class="mono wba">{{ 
selectedSpan.id }}</dd>
+                  <dt v-if="selectedSpan.parentId">{{ t('Parent id') }}</dt>
+                  <dd v-if="selectedSpan.parentId" class="mono wba">{{ 
selectedSpan.parentId }}</dd>
+                  <dt>{{ t('Start') }}</dt><dd class="mono">{{ 
fmtDateTime(selectedSpan.timestamp) }}</dd>
+                  <dt>{{ t('Duration') }}</dt><dd class="mono">{{ 
fmtMs(selectedSpan.duration ?? 0) }}</dd>
+                  <dt>{{ t('Error') }}</dt><dd><span class="status-flag" 
:class="selectedSpan.tags?.error != null ? 'flag-err' : 'flag-ok'"><span 
class="flag-dot" />{{ selectedSpan.tags?.error != null ? t('true') : t('false') 
}}</span></dd>
+                </dl>
+              </section>
+              <section v-if="selectedSpan.tags && 
Object.keys(selectedSpan.tags).length > 0" class="sd-section">
+                <h6>{{ t('Tags') }}</h6>
+                <dl class="kv">
+                  <template v-for="(v, k) in selectedSpan.tags" :key="k">
+                    <dt class="mono">{{ k }}</dt>
+                    <dd class="mono wba" :class="{ err: k === 'error' }">{{ v 
}}</dd>
+                  </template>
+                </dl>
+              </section>
+              <section v-if="selectedSpan.annotations && 
selectedSpan.annotations.length > 0" class="sd-section">
+                <h6>{{ t('Annotations') }}</h6>
+                <div v-for="(a, i) in selectedSpan.annotations" :key="i" 
class="span-event">
+                  <div class="span-event-head">
+                    <span class="mono">{{ a.value }}<span 
v-if="annotationHint(a.value)" class="ann-hint" :title="annotationHint(a.value) 
?? ''"> ({{ annotationHint(a.value) }})</span></span>
+                    <span class="dim mono">{{ fmtDateTime(a.timestamp) 
}}</span>
+                  </div>
+                </div>
+              </section>
+            </div>
+          </article>
+        </div>
+      </div>
+    </template>
+  </article>
+</template>
+
+<style scoped>
+.mono { font-family: var(--sw-mono); }
+.dim { color: var(--sw-fg-3); }
+.wba { word-break: break-all; }
+.hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+
+.ztr-detail {
+  display: flex;
+  flex-direction: column;
+  min-height: 240px;
+  max-height: calc(100vh - 80px);
+  overflow: hidden;
+}
+.ztr-detail-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.ztr-tid { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: 
ellipsis; white-space: nowrap; color: var(--sw-fg-2); font-size: 11px; }
+.ztr-detail-close { margin-left: auto; }
+.ztr-empty {
+  padding: 36px;
+  text-align: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+
+.ztr-detail-body {
+  flex: 1;
+  position: relative;
+  display: flex;
+  min-height: 0;
+  overflow: hidden;
+}
+.ztr-kpis {
+  display: flex;
+  gap: 24px;
+  padding: 8px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.kpi-label {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.kpi-val {
+  font-family: var(--sw-mono);
+  font-size: 13px;
+  color: var(--sw-fg-0);
+  font-weight: 700;
+}
+.ztr-svc-legend {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  flex: 0 0 auto;
+}
+.svc-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  padding: 1px 6px 1px 4px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 10px;
+  background: var(--sw-bg-2);
+}
+.svc-swatch { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+.svc-swatch.inline { display: inline-block; margin-right: 4px; vertical-align: 
middle; }
+
+/* Inline waterfall — each span is a row with absolute-positioned bar. */
+.ztr-waterfall {
+  flex: 1 1 0;
+  min-width: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 4px 0;
+}
+.tp-time-axis {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 12px;
+  background-color: var(--sw-bg-1);
+  border-bottom: 1px solid var(--sw-line);
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.t-tick { white-space: nowrap; }
+.t-tick.first { text-align: left; }
+.t-tick.last { text-align: right; }
+.tp-row { padding: 3px 12px; cursor: pointer; }
+.tp-row:hover { background: var(--sw-bg-2); }
+.tp-row.err { background: rgba(239, 68, 68, 0.06); }
+.tp-row.on { background: var(--sw-accent-soft); }
+.tp-track { position: relative; height: 22px; background: transparent; }
+.t-grid {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background: var(--sw-line);
+  opacity: 0.55;
+  pointer-events: none;
+}
+.t-grid.q1 { left: 25%; }
+.t-grid.q2 { left: 50%; }
+.t-grid.q3 { left: 75%; }
+.tp-bar {
+  position: absolute;
+  top: 2px;
+  bottom: 2px;
+  border-radius: 3px;
+  border: 1px solid transparent;
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+  cursor: pointer;
+  transition: filter 0.12s ease;
+}
+.tp-bar:hover { filter: brightness(1.12); }
+.bar-inner {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 6px;
+  height: 100%;
+  width: 100%;
+  min-width: 0;
+}
+.bar-text {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  min-width: 0;
+  flex: 1 1 auto;
+  overflow: hidden;
+  color: var(--sw-bg-0);
+  text-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
+}
+.bar-svc {
+  font-weight: 700;
+  font-size: 10.5px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 0 1 auto;
+  max-width: 40%;
+  opacity: 0.9;
+}
+.bar-name {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 11px;
+  font-weight: 500;
+}
+.comp-icon { width: 18px; height: 18px; flex: 0 0 auto; object-fit: contain; 
background: transparent; }
+.comp-icon-generic { color: var(--sw-fg-2); }
+.bar-inner .comp-icon-generic { color: rgba(0, 0, 0, 0.72); }
+.bar-inner .status-flag.sm { background: rgba(255, 255, 255, 0.35); color: 
var(--sw-bg-0); }
+.bar-inner .status-flag.flag-err { background: var(--sw-err); color: #fff; }
+.bar-dur-outside {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 10px;
+  color: var(--sw-fg-2);
+  white-space: nowrap;
+  pointer-events: none;
+}
+.bar-dur-inside {
+  flex: 0 0 auto;
+  margin-left: auto;
+  padding-left: 8px;
+  font-size: 10px;
+  color: var(--sw-bg-0);
+  opacity: 0.82;
+  white-space: nowrap;
+}
+
+/* Status flag */
+.status-flag {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  height: 18px;
+  padding: 0 6px;
+  border-radius: 9px;
+  font-size: 9.5px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+  flex: 0 0 auto;
+}
+.status-flag .flag-dot { width: 6px; height: 6px; border-radius: 50%; 
background: currentColor; }
+.status-flag.flag-ok { background: rgba(34, 197, 94, 0.14); color: 
var(--sw-ok); }
+.status-flag.flag-err { background: rgba(239, 68, 68, 0.18); color: 
var(--sw-err); }
+.status-flag.sm { height: 12px; width: 12px; padding: 0; border-radius: 50%; 
justify-content: center; }
+.status-flag.sm .flag-dot { margin: 0; }
+
+/* Span detail modal */
+.span-modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 40px 20px;
+  overflow-y: auto;
+}
+.span-modal {
+  width: 100%;
+  max-width: 920px;
+  max-height: calc(100vh - 80px);
+  display: flex;
+  flex-direction: column;
+}
+.span-modal-head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.span-modal-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  display: inline-flex;
+  gap: 10px;
+  align-items: baseline;
+  flex: 1;
+  min-width: 0;
+}
+.span-modal-head h4 .dim { color: var(--sw-fg-3); font-weight: 500; }
+.span-modal-head h4 .mono {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-1);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.span-modal-body { padding: 12px 14px 16px; overflow-y: auto; }
+.sd-section { margin-bottom: 14px; }
+.sd-section h6 {
+  margin: 0 0 6px;
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-accent);
+  font-weight: 700;
+}
+.kv {
+  display: grid;
+  grid-template-columns: 100px 1fr;
+  gap: 4px 10px;
+  margin: 0;
+  font-size: 11px;
+}
+.kv dt { color: var(--sw-fg-3); font-size: 10.5px; min-width: 0; 
overflow-wrap: anywhere; }
+.kv dd { margin: 0; color: var(--sw-fg-1); min-width: 0; }
+.kv dd.wba { word-break: break-all; }
+.kv dd.err { color: var(--sw-err); }
+.tp-kind { font-family: var(--sw-mono); font-size: 11px; font-weight: 600; }
+.span-event {
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  padding: 6px 10px;
+  margin-bottom: 6px;
+  background: var(--sw-bg-0);
+}
+.span-event-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  font-size: 11px;
+}
+.ann-hint { color: var(--sw-fg-3); }
+
+/* Detail-head buttons. */
+.sw-btn.small {
+  height: 22px;
+  padding: 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  color: var(--sw-fg-1);
+  border-radius: 3px;
+  font-size: 10.5px;
+  cursor: pointer;
+}
+.sw-btn.small:hover { background: var(--sw-bg-3); border-color: 
var(--sw-accent); color: var(--sw-accent); }
+.sw-btn.small.ghost {
+  background: transparent;
+  border-color: transparent;
+  color: var(--sw-fg-3);
+  width: 22px;
+  padding: 0;
+  font-size: 14px;
+}
+.sw-btn.small.ghost:hover { color: var(--sw-err); background: var(--sw-bg-2); }
+.sw-btn.small.copy-btn { background: transparent; color: var(--sw-fg-2); 
white-space: nowrap; }
+</style>

Reply via email to