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>
