This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 6fded53 feat(traces): Zipkin trace view reaches native parity +
unified ?traceId= links (#70)
6fded53 is described below
commit 6fded53d2f1a4ef5445077e04ece9bb268449350
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Tue Jun 23 09:28:37 2026 +0800
feat(traces): Zipkin trace view reaches native parity + unified ?traceId=
links (#70)
## Why
The Zipkin trace view (mesh / k8s / Envoy-ALS layers) looked and behaved
like a second-class citizen next to the SkyWalking-native trace view — a
different waterfall, a side-panel span detail, browser `<datalist>` filters
that wouldn't reopen, and no duration distribution. It also used a separate
`?openZipkinTraceId=` share param even though `/layer/<layer>/trace` already
implies the source.
## What
**Zipkin trace detail + popout now mirror the native trace experience**
(copy-and-adapted, since the wire fields differ):
- KPI strip (started / duration / spans / services) + service legend.
- **Duration-distribution scatter** — drag to filter the list, click a dot
to open the trace (ported from native, adapted to Zipkin's µs units).
- Time-positioned **waterfall bars** with the service · operation +
kind/status affordances *inside* the bar, a time axis, and the duration flipped
inside near-full-width bars (no stray x-scrollbar).
- **Centered span-detail modal** (was a cramped side panel).
- Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) get an inline
plain-language hint; the raw OAP value is kept verbatim.
**Unified shareable links** — both native and Zipkin open from a single
`?traceId=` under the layer's trace tab. The two globally-mounted popouts
self-select by trace-ID shape (native is dotted `x.y.z`, Zipkin is bare
16/32-hex), so `/layer/<layer>/trace?traceId=…` always opens the right viewer.
The separate `?openZipkinTraceId=` param is gone.
**Searchable, on-theme filter dropdowns** — native Instance/Endpoint and
Zipkin Service/Remote/Span name now use the shared `TypeaheadSelect` (dark,
type-to-filter, reopens after a pick); small enums (status/order/limit) stay
plain `<select>`.
---
CHANGELOG.md | 6 +
.../src/components/primitives/TypeaheadSelect.vue | 15 +-
apps/ui/src/i18n/locales/en.json | 9 +-
apps/ui/src/layer/logs/LayerLogsView.vue | 52 +-
apps/ui/src/layer/traces/LayerTracesView.vue | 55 +-
apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 957 +++++++++++++++------
apps/ui/src/layer/traces/ZipkinTracePopout.vue | 534 +++++++-----
apps/ui/src/layer/traces/useTracePopout.ts | 4 +-
apps/ui/src/layer/traces/useZipkinTracePopout.ts | 31 +-
apps/ui/src/shell/AppShell.vue | 6 +-
10 files changed, 1160 insertions(+), 509 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80a13e6..9e43cb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,12 @@ The version line is shared by every package in the monorepo
(apps + shared packa
- **Unified page-size pickers across the event lists.** Traces, Logs, and
Browser Logs share a `20 / 30 / 50 / 100` page-size dropdown — and Browser Logs
gains a picker it never had (it had a fixed 100). Each picker's max matches the
server-side fetch cap in `performance.limits.maxPageSize`.
- **Node memory sizing guidance.** The container image now sets a default
`NODE_OPTIONS=--max-old-space-size`, and the docs cover sizing the Node heap to
your container memory limit and the in-memory source-map budget.
+### Trace explorer
+
+- **Zipkin traces now render with the full native trace experience.** The
Zipkin trace detail and popout gained the KPI strip, service legend, the
duration-distribution scatter (drag to filter, click to open), time-positioned
waterfall bars (service · operation inside the bar, kind/status affordances),
and a centered span-detail modal — matching the SkyWalking-native trace view.
Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) show an inline
plain-language hint.
+- **Shareable trace links are unified.** Native and Zipkin traces both open
from a single `?traceId=` link under the layer's trace tab; the viewer
auto-selects native vs Zipkin by the trace-ID shape, so
`/layer/<layer>/trace?traceId=…` always opens the right one.
+- **Trace filters are searchable, on-theme dropdowns.** The native Service /
Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name
pickers use a dark type-to-filter dropdown that reopens correctly after a pick.
+
## 0.7.0
### Browser errors & source maps
diff --git a/apps/ui/src/components/primitives/TypeaheadSelect.vue
b/apps/ui/src/components/primitives/TypeaheadSelect.vue
index 7c69ebf..edf53d7 100644
--- a/apps/ui/src/components/primitives/TypeaheadSelect.vue
+++ b/apps/ui/src/components/primitives/TypeaheadSelect.vue
@@ -42,6 +42,7 @@ const props = defineProps<{
options: Opt[];
placeholder?: string;
disabled?: boolean;
+ ariaLabel?: string;
/** Minimum width of the dropdown panel. The trigger sizes to its own
* content; the dropdown is `max(triggerWidth, minPanelWidth)`. */
minPanelWidth?: number;
@@ -119,7 +120,15 @@ onBeforeUnmount(() =>
document.removeEventListener('click', onDocClick));
<template>
<div ref="root" class="tas" :class="{ 'is-open': open, 'is-disabled':
disabled }">
- <button type="button" class="tas__trigger" :disabled="disabled"
@click="toggle">
+ <button
+ type="button"
+ class="tas__trigger"
+ :disabled="disabled"
+ :aria-label="ariaLabel"
+ aria-haspopup="listbox"
+ :aria-expanded="open"
+ @click="toggle"
+ >
<span class="tas__label" :class="{ 'tas__label--ph': !selectedLabel }">
{{ selectedLabel || placeholder || t('Select…') }}
</span>
@@ -139,12 +148,14 @@ onBeforeUnmount(() =>
document.removeEventListener('click', onDocClick));
:placeholder="t('Filter…')"
@keydown="onKey"
/>
- <ul class="tas__list">
+ <ul class="tas__list" role="listbox">
<li v-if="filtered.length === 0" class="tas__empty">{{ t('No matches')
}}</li>
<li
v-for="(o, i) in filtered"
:key="String(o.value)"
class="tas__row"
+ role="option"
+ :aria-selected="o.value === modelValue"
:class="{ 'is-active': i === activeIdx, 'is-selected': o.value ===
modelValue }"
@mouseenter="activeIdx = i"
@click="pick(o)"
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 13d3f90..8d77c6c 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1453,5 +1453,12 @@
"Comparing {n} endpoints across services": "Comparing {n} endpoints across
services",
"Failed to load": "Failed to load",
"{n} locked · lock 1 more to compare": "{n} locked · lock 1 more to compare",
- "All": "All"
+ "All": "All",
+ "client send": "client send",
+ "client receive": "client receive",
+ "server receive": "server receive",
+ "server send": "server send",
+ "wire send": "wire send",
+ "wire receive": "wire receive",
+ "error": "error"
}
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 38bc826..6d21655 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -947,25 +947,27 @@ function jumpToTrace(traceId: string, ts?: number): void {
<span v-if="popoutRow.endpointName" class="lg-meta">endpoint
<code>{{ popoutRow.endpointName }}</code></span>
<span v-if="popoutRow.traceId" class="lg-meta">trace <code
class="mono">{{ popoutRow.traceId }}</code></span>
</div>
- <pre
- v-if="detectFormat(popoutRow) === 'json'"
- class="lg-popout-body json"
- >{{ prettyForFormat(popoutRow, 'json') }}</pre>
- <pre
- v-else-if="detectFormat(popoutRow) === 'yaml'"
- class="lg-popout-body yaml"
- >{{ popoutRow.content }}</pre>
- <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre>
- <div v-if="popoutRow.tags.length > 0" class="lg-popout-tags">
- <table class="lg-popout-tag-tbl">
- <thead><tr><th>Key</th><th>Value</th></tr></thead>
- <tbody>
- <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`">
- <td class="mono">{{ t.key }}</td>
- <td class="mono">{{ t.value }}</td>
- </tr>
- </tbody>
- </table>
+ <div class="lg-popout-split">
+ <pre
+ v-if="detectFormat(popoutRow) === 'json'"
+ class="lg-popout-body json"
+ >{{ prettyForFormat(popoutRow, 'json') }}</pre>
+ <pre
+ v-else-if="detectFormat(popoutRow) === 'yaml'"
+ class="lg-popout-body yaml"
+ >{{ popoutRow.content }}</pre>
+ <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre>
+ <aside v-if="popoutRow.tags.length > 0" class="lg-popout-tags">
+ <table class="lg-popout-tag-tbl">
+ <thead><tr><th>Key</th><th>Value</th></tr></thead>
+ <tbody>
+ <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`">
+ <td class="mono">{{ t.key }}</td>
+ <td class="mono">{{ t.value }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </aside>
</div>
</article>
</div>
@@ -1585,8 +1587,11 @@ function jumpToTrace(traceId: string, ts?: number): void
{
background: var(--sw-bg-2);
border-radius: 3px;
}
+.lg-popout-split { flex: 1; min-height: 0; display: flex; }
.lg-popout-body {
flex: 1;
+ min-height: 0;
+ min-width: 0;
margin: 0;
padding: 12px 14px;
background: var(--sw-bg-0);
@@ -1601,16 +1606,19 @@ function jumpToTrace(traceId: string, ts?: number):
void {
.lg-popout-body.json { color: var(--sw-cyan); }
.lg-popout-body.yaml { color: #fbbf24; }
.lg-popout-tags {
- border-top: 1px solid var(--sw-line);
- padding: 8px 14px;
- max-height: 200px;
+ flex: 0 0 340px;
+ border-left: 1px solid var(--sw-line);
+ padding: 8px 14px 12px;
overflow: auto;
+ background: var(--sw-bg-1);
}
.lg-popout-tag-tbl {
width: 100%;
+ table-layout: fixed;
border-collapse: collapse;
font-size: 11px;
}
+.lg-popout-tag-tbl td { word-break: break-all; vertical-align: top; }
.lg-popout-tag-tbl th, .lg-popout-tag-tbl td {
padding: 4px 10px;
text-align: left;
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue
b/apps/ui/src/layer/traces/LayerTracesView.vue
index 9865aa9..35cad5c 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -52,6 +52,7 @@ import { useLayers } from '@/shell/useLayers';
import { useLayerTraces, useTraceDetail } from '@/layer/traces/useLayerTraces';
import { useLayerInstances } from '@/layer/useLayerInstances';
import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
import { useSelectedService } from '@/layer/useSelectedService';
import { useLayerServiceName } from '@/layer/useLayerServiceName';
import { useSetupStore } from '@/state/setup';
@@ -169,6 +170,23 @@ const sourceRef = computed<'native'>(() => 'native');
const { instances } = useLayerInstances(layerKey, serviceName);
const { endpoints } = useLayerEndpoints(layerKey, serviceName, endpointQuery,
ref(50));
+// '' ↔ null bridges the All sentinel (TypeaheadSelect value is a string).
+const instanceSelectOptions = computed(() => [
+ { value: '', label: t('All') },
+ ...instances.value.map((i) => ({ value: i.id, label: i.name })),
+]);
+const endpointSelectOptions = computed(() => [
+ { value: '', label: t('All') },
+ ...endpoints.value.map((e) => ({ value: e.id, label: e.name })),
+]);
+const instanceIdSel = computed<string>({
+ get: () => instanceId.value ?? '',
+ set: (v) => { instanceId.value = v || null; },
+});
+const endpointIdSel = computed<string>({
+ get: () => endpointId.value ?? '',
+ set: (v) => { endpointId.value = v || null; },
+});
watch([serviceName], () => {
instanceId.value = null;
@@ -990,17 +1008,25 @@ onBeforeUnmount(() =>
window.removeEventListener('keydown', onPageKeyDown, true)
<div class="tr-conditions">
<label class="cf">
<span>{{ t('Instance') }}</span>
- <select v-model="instanceId" :disabled="!serviceName"
class="cf-input">
- <option :value="null">{{ t('All') }}</option>
- <option v-for="i in instances" :key="i.id" :value="i.id">{{
i.name }}</option>
- </select>
+ <TypeaheadSelect
+ v-model="instanceIdSel"
+ :aria-label="t('Instance')"
+ :options="instanceSelectOptions"
+ :placeholder="t('All')"
+ :disabled="!serviceName"
+ class="cf-tas"
+ />
</label>
<label class="cf cf-wide">
<span>{{ t('Endpoint') }}</span>
- <select v-model="endpointId" :disabled="!serviceName"
class="cf-input">
- <option :value="null">{{ t('All') }}</option>
- <option v-for="e in endpoints" :key="e.id" :value="e.id">{{
e.name }}</option>
- </select>
+ <TypeaheadSelect
+ v-model="endpointIdSel"
+ :aria-label="t('Endpoint')"
+ :options="endpointSelectOptions"
+ :placeholder="t('All')"
+ :disabled="!serviceName"
+ class="cf-tas"
+ />
</label>
<label class="cf">
<span>{{ t('Status') }}</span>
@@ -1489,7 +1515,7 @@ onBeforeUnmount(() =>
window.removeEventListener('keydown', onPageKeyDown, true)
:d="lnk.d"
class="tree-link"
fill="none"
- stroke="var(--sw-line-2)"
+ stroke="var(--sw-fg-3)"
stroke-width="1.4"
/>
<g
@@ -1799,6 +1825,17 @@ onBeforeUnmount(() =>
window.removeEventListener('keydown', onPageKeyDown, true)
box-sizing: border-box;
}
.cf-input:disabled { opacity: 0.5; cursor: not-allowed; }
+.cf-tas { display: block; width: 100%; }
+.cf-tas :deep(.tas__trigger) {
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ height: 28px;
+ padding: 0 8px;
+ font-size: 11px;
+ background: var(--sw-bg-2);
+ border-radius: 4px;
+}
.cf-range { display: flex; align-items: center; gap: 4px; }
.cf-range-num { flex: 1; min-width: 0; }
.cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; }
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index eb4792a..d027a9b 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -30,8 +30,8 @@ import type { ZipkinTraceListRow } from
'@skywalking-horizon-ui/api-client';
const { t } = useI18n({ useScope: 'global' });
import { useLayerZipkinTraces, useZipkinTrace } from
'@/layer/traces/useZipkinTraces';
import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
-import { readAccent } from '@/utils/cssVar';
import { bffClient } from '@/api/client';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
// Zipkin trace data is keyed by its own service universe (the names
// reported in span `localEndpoint.serviceName`), not by SkyWalking's
@@ -124,6 +124,9 @@ const { traces, isFetching, error, refetch } =
useLayerZipkinTraces({
});
const { openTrace } = useZipkinTracePopout();
+// hasQueried gate: a layer switch leaves cached `traces` stale until refetch.
+const shownTraces = computed<ZipkinTraceListRow[]>(() => (hasQueried.value ?
traces.value : []));
+
function runQuery(): void {
// Service filter is now operator-driven (free-text + datalist), not
// bound to the URL service picker. Empty = "All services" in
@@ -157,30 +160,16 @@ function runQuery(): void {
void refetch();
}
-// URL `?traceId=<id>` opens the popout directly (e.g. log-row link
-// → trace jump). We just route those through the Zipkin popout state.
-watch(
- () => route.query.traceId,
- (raw) => {
- const id = typeof raw === 'string' ? raw : null;
- if (id) openTrace(id);
- },
- { immediate: true },
-);
+// Zipkin's service universe is independent of SkyWalking's per-page
+// picker; the input defaults to All-services and narrows via the
+// /api/v2/services dropdown.
-// Zipkin's service universe is INDEPENDENT of SkyWalking's per-page
-// service picker — different name index (no `<group>::` prefix, no
-// `normal` flag, different list endpoint). We don't seed from the URL
-// SkyWalking service; the input defaults to empty (= All services
-// known to Zipkin) and the operator narrows by picking from the
-// `/api/v2/services` dropdown.
-//
-// Auto-fire one query on landing so the table isn't empty on first
-// arrival — runs with "All" so every layer (mesh / k8s / etc.) shows
-// something immediately.
+// No auto-fire (traces are expensive); a layer switch resets to the prompt.
watch(layerKey, () => {
- if (!hasQueried.value) runQuery();
-}, { immediate: true });
+ hasQueried.value = false;
+ selectedTraceId.value = null;
+ pickedTraceIds.value = new Set();
+});
// Load the full Zipkin service list once for the filter dropdown.
// Best-effort: a failed fetch leaves the input as plain text.
@@ -203,6 +192,18 @@ watch(layerKey, () => { void loadServiceOptions(); }, {
immediate: true });
// "all remotes" endpoint.
const spanNameOptions = ref<string[]>([]);
const remoteSvcOptions = ref<string[]>([]);
+const serviceSelectOptions = computed(() => [
+ { value: '', label: t('All') },
+ ...zipkinServiceOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const remoteSelectOptions = computed(() => [
+ { value: '', label: t('any') },
+ ...remoteSvcOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const spanNameSelectOptions = computed(() => [
+ { value: '', label: t('any') },
+ ...spanNameOptions.value.map((s) => ({ value: s, label: s })),
+]);
async function loadAutocomplete(svc: string): Promise<void> {
if (!svc) {
spanNameOptions.value = [];
@@ -281,8 +282,8 @@ watch(layerKey, (k) => { if (k) void loadAnnotationKeys();
}, { immediate: true
// Mirrors `LayerTracesView`'s native pattern: a row click commits a
// selection to local state instead of opening the global popout.
// The selected trace's full spans render in the side rail. The
-// trace-id input + URL-driven `?openZipkinTraceId=` still fall back
-// to the popout for the "paste an id" / "share a link" flows.
+// trace-id input + URL-driven `?traceId=` still fall back to the
+// popout for the "paste an id" / "share a link" flows.
const selectedTraceId = ref<string | null>(null);
function selectRow(row: ZipkinTraceListRow): void {
selectedTraceId.value = row.traceId;
@@ -290,14 +291,20 @@ 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. */
const railOpen = ref<boolean>(true);
-const selectedRow = computed<ZipkinTraceListRow | null>(() => {
- if (!selectedTraceId.value) return null;
- return traces.value.find((t) => t.traceId === selectedTraceId.value) ?? null;
-});
// Reset when the query parameters change so a stale selection
// doesn't outlive its result set.
watch([cService, cLookback, cLimit, cSpan, cRemote, cAnno], () => {
@@ -309,8 +316,7 @@ watch([cService, cLookback, cLimit, cSpan, cRemote, cAnno],
() => {
// waterfall row list. Reused-but-simplified from `ZipkinTracePopout`:
// same parent-id walk, fewer per-row affordances since the rail is
// narrower than the popout.
-const selectedTraceIdRef = computed<string | null>(() =>
selectedTraceId.value);
-const { spans: selectedSpans, isLoading: selectedLoading } =
useZipkinTrace(selectedTraceIdRef);
+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;
@@ -337,8 +343,11 @@ const detailRows = computed<DetailRow[]>(() => {
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);
@@ -356,27 +365,63 @@ const detailRows = computed<DetailRow[]>(() => {
}
return out;
});
-/* Per-service color palette. First entry tracks `--sw-accent` so the
- * trace waterfall's brand color follows the active theme; the rest
- * stay constant because their job is "be distinct from each other"
- * across services, not "match brand". Rebuilt per call so theme
- * swaps land immediately on next render. */
+// 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-3)';
- const palette = [readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee',
'#f472b6', '#34d399', '#fbbf24', '#fb7185'];
- let h = 0;
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
- return palette[Math.abs(h) % palette.length]!;
+ 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.6;
- return Math.max(0.6, Math.min(100, (us / (detailBounds.value.totalUs || 1))
* 100));
-}
-function expandInPopout(): void {
- if (selectedTraceId.value) openTrace(selectedTraceId.value);
+ 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) ───────────────────────
@@ -399,15 +444,12 @@ function clearSpan(): void {
watch(selectedTraceId, () => { selectedSpanId.value = null; });
// Dismiss the floating span-detail overlay on Escape or click-outside.
-// A click on another `.ztr-wf-row` is ignored here — the row's own
-// handler swaps to that span; if we closed first we'd lose the new pin.
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;
- if (t.closest?.('.ztr-wf-row')) return;
clearSpan();
}
function onSpanDetailKey(e: KeyboardEvent): void {
@@ -423,14 +465,9 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onSpanDetailDocClick);
document.removeEventListener('keydown', onSpanDetailKey);
+ if (autocompleteTimer) clearTimeout(autocompleteTimer);
});
-function fmtAbsTime(usSinceEpoch: number): string {
- if (!usSinceEpoch) return '—';
- const d = new Date(usSinceEpoch / 1000);
- return `${d.toLocaleTimeString('en-US', { hour12: false
})}.${String(d.getMilliseconds()).padStart(3, '0')}`;
-}
-
// ── Rendering helpers ───────────────────────────────────────────
function fmtMs(us: number | null): string {
if (us == null) return '—';
@@ -441,14 +478,17 @@ function fmtMs(us: number | null): string {
// `fmtTime` removed — absolute start moved to `fmtRelativeAgo` in
// the row meta strip; the card layout drops the wall-clock column.
const sortedTraces = computed<ZipkinTraceListRow[]>(() => {
- return [...traces.value].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ??
0));
+ const rows = pickedTraceIds.value.size > 0
+ ? shownTraces.value.filter((r) => pickedTraceIds.value.has(r.traceId))
+ : shownTraces.value;
+ return [...rows].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
});
/** Largest duration in the visible set — drives the proportional
* fill of each row's duration bar. Matches the native trace tab's
* "% of slowest" presentation. */
const maxTraceDurationUs = computed<number>(() => {
let m = 0;
- for (const t of traces.value) {
+ for (const t of shownTraces.value) {
if ((t.duration ?? 0) > m) m = t.duration ?? 0;
}
return m || 1;
@@ -475,10 +515,131 @@ function openByInput(): void {
const v = traceIdInput.value.trim();
if (v) openTrace(v);
}
+
+// ── Duration scatter ──────────────────────────────────────────────
+// x = trace start (µs since epoch), y = duration (µs). Click a dot to
+// open the inline detail; drag a box to pick a set and filter the list.
+interface ScatterPoint { id: string; x: number; y: number; isError: boolean;
label: string; row: ZipkinTraceListRow; }
+const scatterPoints = computed<ScatterPoint[]>(() =>
+ shownTraces.value
+ .filter((r): r is ZipkinTraceListRow & { timestamp: number; duration:
number } =>
+ r.timestamp != null && r.timestamp > 0 && r.duration != null &&
r.duration >= 0)
+ .map((r) => ({
+ id: r.traceId,
+ x: r.timestamp,
+ y: r.duration,
+ isError: r.errorCount > 0,
+ label: r.rootName ?? r.rootService ?? '—',
+ row: r,
+ })),
+);
+const scatterBounds = computed(() => {
+ const pts = scatterPoints.value.filter((p) => p.x > 0 &&
Number.isFinite(p.y));
+ if (pts.length === 0) return null;
+ const xs = pts.map((p) => p.x);
+ const ys = pts.map((p) => p.y);
+ return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax:
Math.max(...ys, 1) };
+});
+const scatterXTicks = computed(() => {
+ const b = scatterBounds.value;
+ if (!b) return [];
+ const xCount = 3;
+ const span = Math.max(1, b.xMax - b.xMin);
+ return Array.from({ length: xCount }, (_, i) => {
+ // x is µs since epoch — divide by 1000 for the JS Date.
+ const d = new Date((b.xMin + (span * i) / (xCount - 1)) / 1000);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return { frac: i / (xCount - 1), label:
`${pad(d.getHours())}:${pad(d.getMinutes())}` };
+ });
+});
+
+// Clicking a dot opens the inline detail; dragging a box picks every
+// dot inside it and the list narrows to the picked set — no extra
+// query fires. Reset clears it.
+const pickedTraceIds = ref<Set<string>>(new Set());
+const isPicking = computed(() => pickedTraceIds.value.size > 0);
+function resetPick(): void {
+ pickedTraceIds.value = new Set();
+}
+// Drop a stale pick when the result set changes.
+watch(traces, () => { pickedTraceIds.value = new Set(); });
+
+const scatterSvgRef = ref<SVGSVGElement | null>(null);
+const dragState = ref<{
+ active: boolean;
+ startVx: number; startVy: number; curVx: number; curVy: number;
+}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 });
+function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null {
+ const svg = scatterSvgRef.value;
+ if (!svg) return null;
+ const rect = svg.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return null;
+ return {
+ vx: ((ev.clientX - rect.left) / rect.width) * 1000,
+ vy: ((ev.clientY - rect.top) / rect.height) * 1000,
+ };
+}
+function onScatterDown(ev: PointerEvent): void {
+ const pt = clientToViewbox(ev);
+ if (!pt) return;
+ const target = ev.target as Element | null;
+ // Dot clicks are handled by the circle's own handler; drag is for the
+ // empty plot area.
+ if (target?.classList.contains('scatter-dot')) return;
+ ev.preventDefault();
+ dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx:
pt.vx, curVy: pt.vy };
+ (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId);
+}
+function onScatterMove(ev: PointerEvent): void {
+ if (!dragState.value.active) return;
+ const pt = clientToViewbox(ev);
+ if (!pt) return;
+ dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy };
+}
+function onScatterUp(ev: PointerEvent): void {
+ if (!dragState.value.active) return;
+ const { startVx, startVy, curVx, curVy } = dragState.value;
+ dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy:
0 };
+ try {
+ (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId);
+ } catch { /* noop */ }
+ // A click without movement leaves the selection untouched.
+ if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return;
+ const b = scatterBounds.value;
+ if (!b) return;
+ const vxMin = Math.min(startVx, curVx);
+ const vxMax = Math.max(startVx, curVx);
+ const vyMin = Math.min(startVy, curVy);
+ const vyMax = Math.max(startVy, curVy);
+ const next = new Set(pickedTraceIds.value);
+ for (const p of scatterPoints.value) {
+ const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin))
* 1000;
+ const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990;
+ if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax)
next.add(p.id);
+ }
+ pickedTraceIds.value = next;
+}
+const dragRect = computed(() => {
+ const s = dragState.value;
+ if (!s.active) return null;
+ return {
+ x: Math.min(s.startVx, s.curVx),
+ y: Math.min(s.startVy, s.curVy),
+ w: Math.abs(s.curVx - s.startVx),
+ h: Math.abs(s.curVy - s.startVy),
+ };
+});
+// Dot click opens the inline detail for that trace.
+function pickScatterDot(p: ScatterPoint, ev: MouseEvent): void {
+ ev.stopPropagation();
+ selectRow(p.row);
+}
</script>
<template>
<div class="ztr-tab">
+ <!-- Top strip: filter (80%) + duration distribution (20%). -->
+ <div class="ztr-top-strip">
<header class="ztr-toolbar sw-card">
<div class="ztr-head">
<span class="kicker">{{ t('Traces · Zipkin') }}</span>
@@ -493,51 +654,39 @@ function openByInput(): void {
<button class="sw-btn primary ztr-run-btn" type="button"
@click="runQuery">{{ t('Run query') }}</button>
</div>
<div class="ztr-conditions">
- <!-- Service condition — free-text + datalist with All. Empty
+ <!-- Service condition — on-theme TypeaheadSelect. Empty value
commits as `null` (= every service) to the Zipkin query. -->
<label class="cf">
<span>{{ t('Service') }}</span>
- <input
+ <TypeaheadSelect
v-model="zipkinServiceFilter"
- class="cf-input"
- type="text"
+ :aria-label="t('Service')"
+ :options="serviceSelectOptions"
:placeholder="t('All')"
- list="ztr-svc-suggest"
- @keyup.enter="runQuery"
+ class="cf-tas"
/>
- <datalist id="ztr-svc-suggest">
- <option v-for="s in zipkinServiceOptions" :key="s" :value="s" />
- </datalist>
</label>
<label class="cf" :class="{ disabled: !hasService }">
<span>{{ t('Remote service') }} <small v-if="!hasService"
class="dim">— {{ t('pick a service') }}</small></span>
- <input
+ <TypeaheadSelect
v-model="remoteServiceName"
- class="cf-input"
- type="text"
+ :aria-label="t('Remote service')"
+ :options="remoteSelectOptions"
:placeholder="hasService ? t('any') : t('select a service first')"
:disabled="!hasService"
- list="ztr-remote-svc-suggest"
- @keyup.enter="runQuery"
+ class="cf-tas"
/>
- <datalist id="ztr-remote-svc-suggest">
- <option v-for="rs in remoteSvcOptions" :key="rs" :value="rs" />
- </datalist>
</label>
<label class="cf cf-wide" :class="{ disabled: !hasService }">
<span>{{ t('Span name') }} <small v-if="!hasService" class="dim">—
{{ t('pick a service') }}</small></span>
- <input
+ <TypeaheadSelect
v-model="spanName"
- class="cf-input"
- type="text"
+ :aria-label="t('Span name')"
+ :options="spanNameSelectOptions"
:placeholder="hasService ? t('any') : t('select a service first')"
:disabled="!hasService"
- list="ztr-span-suggest"
- @keyup.enter="runQuery"
+ class="cf-tas"
/>
- <datalist id="ztr-span-suggest">
- <option v-for="sp in spanNameOptions" :key="sp" :value="sp" />
- </datalist>
</label>
<label class="cf">
<span>{{ t('Min duration (ms)') }}</span>
@@ -607,6 +756,80 @@ function openByInput(): void {
</div>
</header>
+ <!-- Distribution: dots only. Click a dot to open its trace; drag
+ a box to narrow the list. -->
+ <section class="ztr-scatter sw-card">
+ <header class="ztr-scatter-head">
+ <span v-if="!isPicking" class="kicker">{{ t('Distribution') }}</span>
+ <span v-else class="kicker pick-kicker">{{ t('{n} picked', { n:
pickedTraceIds.size }) }}</span>
+ <span class="legend">
+ <span class="lg ok" /> {{ t('ok') }}
+ <span class="lg err" /> {{ t('err') }}
+ </span>
+ <button
+ v-if="isPicking"
+ class="sw-btn small ghost reset-btn"
+ type="button"
+ :title="t('Clear in-page filter')"
+ @click="resetPick"
+ >{{ t('Reset') }}</button>
+ </header>
+ <div v-if="scatterPoints.length > 0 && scatterBounds"
class="scatter-wrap">
+ <svg
+ ref="scatterSvgRef"
+ class="scatter-svg"
+ viewBox="0 0 1000 1000"
+ preserveAspectRatio="none"
+ @pointerdown="onScatterDown"
+ @pointermove="onScatterMove"
+ @pointerup="onScatterUp"
+ @pointercancel="onScatterUp"
+ >
+ <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)"
stroke-width="1" vector-effect="non-scaling-stroke" />
+ <circle
+ v-for="p in scatterPoints"
+ :key="p.id"
+ :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x -
scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000"
+ :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax -
scatterBounds.yMin || 1)) * 990"
+ :r="pickedTraceIds.has(p.id) ? 6 : 3.2"
+ :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'"
+ :fill-opacity="pickedTraceIds.has(p.id) ? 1 : (isPicking ? 0.35
: 0.9)"
+ :stroke="pickedTraceIds.has(p.id) ? 'var(--sw-fg-0)' :
(p.isError ? 'var(--sw-err)' : 'var(--sw-accent-2)')"
+ :stroke-width="pickedTraceIds.has(p.id) ? 1.8 : 0.8"
+ vector-effect="non-scaling-stroke"
+ class="scatter-dot"
+ @click="pickScatterDot(p, $event)"
+ >
+ <title>{{ p.label }} · {{ fmtMs(p.y) }}{{
pickedTraceIds.has(p.id) ? ` · ${t('picked')}` : '' }}</title>
+ </circle>
+ <rect
+ v-if="dragRect"
+ :x="dragRect.x"
+ :y="dragRect.y"
+ :width="dragRect.w"
+ :height="dragRect.h"
+ fill="var(--sw-accent)"
+ fill-opacity="0.12"
+ stroke="var(--sw-accent)"
+ stroke-width="1"
+ stroke-dasharray="4 3"
+ vector-effect="non-scaling-stroke"
+ pointer-events="none"
+ />
+ </svg>
+ <div class="scatter-x-axis">
+ <span
+ v-for="(tick, i) in scatterXTicks"
+ :key="`x${i}`"
+ class="x-tick"
+ :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }"
+ >{{ tick.label }}</span>
+ </div>
+ </div>
+ <div v-else class="scatter-empty">{{ t('no traces') }}</div>
+ </section>
+ </div>
+
<!-- No selection — full-width list. Once an operator clicks a
row, the section flips below to the rail+detail split. -->
<section v-if="!selectedTraceId" class="ztr-split">
@@ -740,108 +963,135 @@ function openByInput(): void {
<article class="ztr-detail sw-card">
<header class="ztr-detail-head">
<span class="kicker">{{ t('Trace') }}</span>
- <code class="ztr-tid mono">{{ selectedTraceId.slice(0, 18) }}…</code>
- <span v-if="selectedRow" class="dim small">{{
fmtMs(selectedRow.duration) }} · {{ t('{n} spans', { n: selectedRow.spanCount
}) }}</span>
- <button class="sw-btn small" type="button"
@click="expandInPopout">{{ t('Expand') }}</button>
- <button class="sw-btn small ghost" type="button"
@click="closeDetail">×</button>
+ <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>
- <div v-else class="ztr-detail-body">
- <div class="ztr-waterfall">
- <div
- v-for="row in detailRows"
- :key="row.span.id"
- class="ztr-wf-row"
- :class="{
- err: row.span.tags?.error != null,
- on: selectedSpanId === row.span.id,
- }"
- :title="row.span.name ?? ''"
- @click="selectSpan(row.span)"
- >
- <span class="ztr-wf-label mono" :style="{ paddingLeft: row.depth
* 12 + 'px' }">
- <span class="ztr-wf-svc" :style="{ color:
detailColor(row.span.localEndpoint?.serviceName) }">
- {{ row.span.localEndpoint?.serviceName ?? '—' }}
- </span>
- <span class="ztr-wf-name">{{ row.span.name || t('(unnamed)')
}}</span>
- </span>
- <div class="ztr-wf-track">
- <div
- class="ztr-wf-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',
- }"
- />
+ <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>
- <span class="ztr-wf-dur mono">{{ fmtMs(row.durUs) }}</span>
</div>
- </div>
- <!-- Span detail floats as a right-edge overlay (width capped
- at min(640px, 60%)) so it can render long tag values
- without compressing the waterfall bars. Click ×, click
- the same span, or pick another span to dismiss / swap. -->
- <aside v-if="selectedSpan" ref="spanDetailRef"
class="ztr-span-detail">
- <header class="ztr-span-detail-head">
- <h5>{{ t('Span detail') }}</h5>
+ <!-- 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>
- <dl class="zk-kv">
- <dt>{{ t('Service') }}</dt>
- <dd
- class="mono"
- :style="{ color:
detailColor(selectedSpan.localEndpoint?.serviceName) }"
- >
- {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
- </dd>
- <dt>{{ t('Name') }}</dt><dd class="mono wba">{{
selectedSpan.name || t('(unnamed)') }}</dd>
- <dt v-if="selectedSpan.kind">{{ t('Kind') }}</dt>
- <dd v-if="selectedSpan.kind">{{ selectedSpan.kind }}</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">{{
fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
- <dt>{{ t('Duration') }}</dt><dd class="mono">{{
fmtMs(selectedSpan.duration ?? 0) }}</dd>
- <dt v-if="selectedSpan.remoteEndpoint?.serviceName">{{ t('Peer')
}}</dt>
- <dd v-if="selectedSpan.remoteEndpoint?.serviceName" 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>
- </dl>
- <section
- v-if="selectedSpan.tags && Object.keys(selectedSpan.tags).length
> 0"
- class="zk-tags"
- >
+ <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="zk-kv">
+ <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="zk-annotations"
- >
+ <section v-if="selectedSpan.annotations &&
selectedSpan.annotations.length > 0" class="sd-section">
<h6>{{ t('Annotations') }}</h6>
- <ul>
- <li v-for="(a, i) in selectedSpan.annotations" :key="i">
- <span class="mono dim">{{ fmtAbsTime(a.timestamp) }}</span>
- <span class="zk-ann-val mono">{{ a.value }}</span>
- </li>
- </ul>
+ <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>
- </aside>
- </div>
+ </div>
+ </article>
+ </div>
+ </div>
+ </template>
</article>
</section>
</div>
@@ -849,6 +1099,15 @@ function openByInput(): void {
<style scoped>
.ztr-tab { display: flex; flex-direction: column; gap: 12px; padding: 4px 0 0;
}
+.ztr-top-strip {
+ display: grid;
+ grid-template-columns: 4fr 1fr;
+ gap: 12px;
+ align-items: stretch;
+}
+@media (max-width: 1100px) { .ztr-top-strip { grid-template-columns: 1fr; } }
+.ztr-top-strip .ztr-toolbar,
+.ztr-top-strip .ztr-scatter { margin: 0; }
.ztr-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap:
10px; overflow: visible; }
.ztr-head { display: flex; align-items: baseline; gap: 10px; }
.ztr-run-btn { margin-left: auto; }
@@ -901,6 +1160,17 @@ function openByInput(): void {
cursor: not-allowed;
background: var(--sw-bg-1);
}
+.cf-tas { display: block; width: 100%; }
+.cf-tas :deep(.tas__trigger) {
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ height: 28px;
+ padding: 0 8px;
+ font-size: 11px;
+ background: var(--sw-bg-2);
+ border-radius: 4px;
+}
.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; }
.cf .dim { color: var(--sw-fg-3); }
@@ -918,6 +1188,68 @@ function openByInput(): void {
}
.sw-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.sw-btn.primary:hover:not(:disabled) { background: var(--sw-accent-2); }
+
+/* ── Duration distribution scatter ───────────────────────────────
+ Ported from `LayerTracesView`'s `.tr-scatter` so the native +
+ Zipkin trace tabs read identically. */
+.ztr-scatter {
+ padding: 6px 10px 8px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.ztr-scatter-head { display: flex; align-items: baseline; gap: 10px;
margin-bottom: 2px; flex: 0 0 auto; }
+.ztr-scatter-head .legend { margin-left: auto; font-size: 10.5px; color:
var(--sw-fg-3); display: inline-flex; gap: 10px; align-items: center; }
+.ztr-scatter-head .lg {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ margin-right: 3px;
+ vertical-align: middle;
+}
+.ztr-scatter-head .lg.ok { background: var(--sw-accent); }
+.ztr-scatter-head .lg.err { background: var(--sw-err); }
+.pick-kicker { color: var(--sw-accent-2); font-weight: 700; }
+.reset-btn { margin-left: 6px; }
+.scatter-wrap {
+ padding: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.scatter-svg {
+ cursor: crosshair;
+ width: 100%;
+ flex: 1;
+ min-height: 140px;
+ display: block;
+}
+.scatter-dot { cursor: pointer; transition: r 0.12s ease; }
+.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; }
+.scatter-x-axis {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 0 0;
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ font-family: var(--sw-mono);
+ flex: 0 0 auto;
+}
+.x-tick { white-space: nowrap; }
+.x-tick.first { text-align: left; }
+.x-tick.last { text-align: right; }
+.scatter-empty {
+ flex: 1;
+ min-height: 140px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ color: var(--sw-fg-3);
+}
+
.ztr-list { display: flex; flex-direction: column; min-height: 240px; }
.ztr-list-head {
display: flex;
@@ -1042,9 +1374,6 @@ function openByInput(): void {
grid-template-columns: 1fr;
gap: 12px;
}
-.ztr-split.has-detail {
- grid-template-columns: minmax(0, 1.4fr) minmax(360px, 1fr);
-}
.ztr-detail {
display: flex;
flex-direction: column;
@@ -1065,13 +1394,9 @@ function openByInput(): void {
padding: 10px 14px;
border-bottom: 1px solid var(--sw-line);
}
-.ztr-tid { flex: 1; color: var(--sw-fg-2); font-size: 11px; }
+.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; }
-/* When a span is pinned, the detail panel floats over the right edge
- of the waterfall as an overlay so it can be wide enough for long tag
- values without compressing the waterfall bars. min(640px, 60%) caps
- the panel at 640px on wide layouts but yields gracefully on narrow
- ones. Pop-out has the same pattern (.zk-detail). */
.ztr-detail-body {
flex: 1;
position: relative;
@@ -1079,69 +1404,88 @@ function openByInput(): void {
min-height: 0;
overflow: hidden;
}
-.ztr-span-detail {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- width: min(640px, 60%);
+.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;
- padding: 12px 14px;
- background: var(--sw-bg-1);
- border-left: 1px solid var(--sw-line);
- box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
- z-index: 5;
}
-.ztr-span-detail-head {
+.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;
- justify-content: space-between;
- margin-bottom: 8px;
+ gap: 12px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ flex: 0 0 auto;
}
-.ztr-span-detail-head h5 {
+.span-modal-head h4 {
margin: 0;
font-size: 12px;
- color: var(--sw-fg-0);
+ 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;
}
-.zk-tags, .zk-annotations { margin-top: 12px; }
-.zk-tags h6, .zk-annotations h6 {
+.span-modal-body { padding: 12px 14px 16px; overflow-y: auto; }
+.sd-section { margin-bottom: 14px; }
+.sd-section h6 {
margin: 0 0 6px;
- font-size: 10px;
+ font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
- color: var(--sw-fg-3);
- font-weight: 600;
+ color: var(--sw-accent);
+ font-weight: 700;
}
-.zk-kv {
+.kv {
display: grid;
- grid-template-columns: max-content 1fr;
- gap: 4px 12px;
- font-size: 11px;
+ grid-template-columns: 100px 1fr;
+ gap: 4px 10px;
margin: 0;
+ font-size: 11px;
}
-.zk-kv dt { color: var(--sw-fg-3); }
-.zk-kv dd { margin: 0; color: var(--sw-fg-1); }
-.zk-kv dd.wba { word-break: break-all; }
-.zk-kv dd.err { color: var(--sw-err); }
-.zk-annotations ul {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 4px;
+.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);
}
-.zk-annotations li {
- display: grid;
- grid-template-columns: 100px 1fr;
- gap: 8px;
- font-size: 10.5px;
+.span-event-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 11px;
}
-.zk-annotations .dim { color: var(--sw-fg-3); }
-.zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
-
-/* Floating overlay scales with the container via min(640px, 60%) — no
- width breakpoints needed. */
+.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
@@ -1150,72 +1494,166 @@ function openByInput(): void {
.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-wf-row {
- display: grid;
- grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr) 64px;
- gap: 8px;
- padding: 3px 12px;
+.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;
}
-.ztr-wf-row:hover { background: var(--sw-bg-2); }
-.ztr-wf-row.on {
- background: var(--sw-accent-soft);
- box-shadow: inset 2px 0 0 var(--sw-accent);
+.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;
}
-.ztr-wf-row.err { box-shadow: inset 3px 0 0 var(--sw-err); }
-.ztr-wf-row.on.err { box-shadow: inset 3px 0 0 var(--sw-err), inset 0 0 0 1px
var(--sw-accent); }
-.ztr-wf-label {
+.bar-text {
display: inline-flex;
align-items: baseline;
gap: 6px;
- font-size: 10.5px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
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);
}
-.ztr-wf-svc {
- flex: 0 0 auto;
+.bar-svc {
font-weight: 700;
- max-width: 90px;
+ font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 0 1 auto;
+ max-width: 40%;
+ opacity: 0.9;
}
-.ztr-wf-name {
+.bar-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
- color: var(--sw-fg-1);
-}
-.ztr-wf-track {
- position: relative;
- height: 14px;
- background: var(--sw-bg-1);
- border-radius: 2px;
+ white-space: nowrap;
+ font-size: 11px;
+ font-weight: 500;
}
-.ztr-wf-bar {
+.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: 1px;
- bottom: 1px;
- border-radius: 2px;
- border: 1px solid transparent;
-}
-.ztr-wf-dur {
+ top: 50%;
+ transform: translateY(-50%);
font-size: 10px;
- text-align: right;
color: var(--sw-fg-2);
- font-variant-numeric: tabular-nums;
+ 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
would crowd the table otherwise. */
- .ztr-split.has-detail { grid-template-columns: 1fr; }
.ztr-detail-split { grid-template-columns: 1fr !important; }
}
@@ -1354,4 +1792,5 @@ function openByInput(): 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/layer/traces/ZipkinTracePopout.vue
b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
index 71ca440..01f1726 100644
--- a/apps/ui/src/layer/traces/ZipkinTracePopout.vue
+++ b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
@@ -36,7 +36,6 @@ import type { ZipkinSpan } from
'@skywalking-horizon-ui/api-client';
const { t } = useI18n({ useScope: 'global' });
import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
import { useZipkinTrace } from '@/layer/traces/useZipkinTraces';
-import { readAccent } from '@/utils/cssVar';
const { openTraceId, closeTrace } = useZipkinTracePopout();
const traceIdRef = computed(() => openTraceId.value);
@@ -132,33 +131,68 @@ function clearSpan(): void {
// Drop the selection when the trace changes.
watch(traceIdRef, () => { selectedSpanId.value = null; });
-// Floating span-detail dismiss — click outside the .zk-detail panel
-// (but not on another row, which has its own swap handler) clears the
-// pin. Backdrop clicks still close the whole popout via the existing
-// `@click.self` on .zk-popout-backdrop.
+// Click outside the span-detail modal card dismisses it.
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;
- if (t.closest?.('.zk-row')) return;
clearSpan();
}
-// ── Color per service so each row reads as a band ────────────────
-// First entry tracks `--sw-accent` so the brand color in the trace
-// waterfall follows the active theme. Rest stay constant for service
-// differentiation.
+// 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 serviceColor(name: string | null | undefined): string {
- if (!name) return 'var(--sw-fg-3)';
- const palette = [
- readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee',
- '#f472b6', '#34d399', '#fbbf24', '#fb7185',
- ];
- let h = 0;
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
- return palette[Math.abs(h) % palette.length]!;
+ if (!name) return 'var(--sw-fg-2)';
+ return SERVICE_PALETTE[hashString(name) % SERVICE_PALETTE.length]!;
+}
+const serviceColors = computed<Map<string, string>>(() => {
+ const m = new Map<string, string>();
+ for (const s of spans.value) {
+ const n = s.localEndpoint?.serviceName ?? '—';
+ if (!m.has(n)) m.set(n, serviceColor(n));
+ }
+ return m;
+});
+const rootStart = computed<number | null>(() => {
+ const ts = spans.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)';
+}
+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())}`;
+}
+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 {
+ return ANNOTATION_LABELS[value.trim().toLowerCase()] ?? null;
+}
+function copyShareableUrl(): void {
+ if (typeof window === 'undefined') return;
+ navigator.clipboard?.writeText(window.location.href).catch(() => {});
}
// ── Formatting ────────────────────────────────────────────────────
@@ -167,18 +201,14 @@ function fmtMs(us: number): string {
if (us < 1_000_000) return `${(us / 1000).toFixed(2)}ms`;
return `${(us / 1_000_000).toFixed(2)}s`;
}
-function fmtAbsTime(usSinceEpoch: number): string {
- if (!usSinceEpoch) return '';
- return new Date(usSinceEpoch / 1000).toLocaleString();
-}
function offsetPct(us: number): number {
const total = bounds.value.totalUs || 1;
return Math.max(0, Math.min(100, (us / total) * 100));
}
function widthPct(us: number): number {
- if (us <= 0) return 0.4; // give zero-duration spans a sliver so they render
+ if (us <= 0) return 0.8; // give zero-duration spans a sliver so they render
const total = bounds.value.totalUs || 1;
- return Math.max(0.4, Math.min(100, (us / total) * 100));
+ return Math.max(0.8, Math.min(100, (us / total) * 100));
}
// ── ESC + backdrop close ─────────────────────────────────────────
@@ -233,8 +263,9 @@ function copyTraceId(): void {
<article class="zk-popout sw-card">
<header class="zk-head">
<span class="kicker">{{ t('Zipkin trace') }}</span>
- <code class="zk-tid mono">{{ openTraceId }}</code>
- <button class="sw-btn small" type="button" @click="copyTraceId">{{
t('Copy id') }}</button>
+ <code class="zk-tid mono" :title="openTraceId ?? ''">{{ openTraceId
}}</code>
+ <button class="sw-btn small ghost" type="button" :title="t('Copy id')"
@click="copyTraceId">⧉ {{ t('id') }}</button>
+ <button class="sw-btn small ghost" type="button" :title="t('Copy
shareable URL')" @click="copyShareableUrl">⧉ {{ t('url') }}</button>
<span v-if="isLoading" class="hint">{{ t('loading…') }}</span>
<span v-if="error" class="hint err">{{ String(error) }}</span>
<button class="sw-btn small ghost zk-close" type="button"
@click="closeTrace">×</button>
@@ -244,10 +275,21 @@ function copyTraceId(): void {
{{ t('No spans returned for this trace.') }}
</div>
- <div v-else class="zk-split">
- <!-- Waterfall column -->
+ <template v-else>
+ <div class="tp-kpis">
+ <div><div class="kpi-label">{{ t('started') }}</div><div
class="kpi-val">{{ fmtDateTime(rootStart) }}</div></div>
+ <div><div class="kpi-label">{{ t('duration') }}</div><div
class="kpi-val">{{ fmtMs(bounds.totalUs) }}</div></div>
+ <div><div class="kpi-label">{{ t('spans') }}</div><div
class="kpi-val">{{ waterfall.length }}</div></div>
+ <div><div class="kpi-label">{{ t('services') }}</div><div
class="kpi-val">{{ serviceColors.size }}</div></div>
+ </div>
+ <div v-if="serviceColors.size > 0" class="tp-svc-legend">
+ <span v-for="[code, color] in serviceColors" :key="code"
class="svc-chip">
+ <span class="svc-swatch" :style="{ background: color }" />
+ <span class="mono">{{ code }}</span>
+ </span>
+ </div>
<div class="zk-waterfall">
- <div class="zk-time-axis">
+ <div class="tp-time-axis">
<span class="t-tick first">0</span>
<span class="t-tick">{{ fmtMs(bounds.totalUs * 0.25) }}</span>
<span class="t-tick">{{ fmtMs(bounds.totalUs * 0.5) }}</span>
@@ -257,81 +299,96 @@ function copyTraceId(): void {
<div
v-for="row in waterfall"
:key="row.span.id"
- class="zk-row"
+ class="tp-row"
:class="{ on: selectedSpanId === row.span.id, err:
row.span.tags?.error != null }"
@click="selectSpan(row.span)"
>
- <span class="zk-row-label mono" :style="{ paddingLeft: row.depth *
14 + 'px' }">
- <span class="zk-row-svc" :style="{ color:
serviceColor(row.span.localEndpoint?.serviceName) }">
- {{ row.span.localEndpoint?.serviceName ?? '—' }}
- </span>
- <span class="zk-row-name">{{ row.span.name || t('(unnamed)')
}}</span>
- </span>
- <div class="zk-track">
+ <div class="tp-track">
<span class="t-grid q1" /><span class="t-grid q2" /><span
class="t-grid q3" />
<div
- class="zk-bar"
+ class="tp-bar"
:style="{
left: offsetPct(row.startOffsetUs) + '%',
width: widthPct(row.durationUs) + '%',
background:
serviceColor(row.span.localEndpoint?.serviceName),
borderColor: row.span.tags?.error != null ? 'var(--sw-err)'
: 'transparent',
}"
- />
- <span class="zk-dur mono" :style="{ left:
`calc(${offsetPct(row.startOffsetUs + row.durationUs)}% + 6px)` }">
- {{ fmtMs(row.durationUs) }}
- </span>
+ >
+ <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="widthPct(row.durationUs) > 12"
class="bar-dur-inside mono">{{ fmtMs(row.durationUs) }}</span>
+ </span>
+ </div>
+ <span
+ v-if="widthPct(row.durationUs) <= 12"
+ class="bar-dur-outside mono"
+ :style="{ left: `calc(${offsetPct(row.startOffsetUs +
row.durationUs)}% + 6px)` }"
+ >{{ fmtMs(row.durationUs) }}</span>
</div>
</div>
</div>
+ </template>
- <!-- Span detail floats as a right-edge overlay; width caps at
- min(640px, 60%) so long tag values render without
- compressing the waterfall. Mirrors LayerZipkinTracesView. -->
- <aside v-if="selectedSpan" ref="spanDetailRef" class="zk-detail">
- <header class="zk-detail-head">
- <h5>{{ t('Span detail') }}</h5>
- <button class="sw-btn small ghost" type="button"
@click="clearSpan">×</button>
+ <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>
- <dl class="zk-kv">
- <dt>{{ t('Service') }}</dt>
- <dd class="mono" :style="{ color:
serviceColor(selectedSpan.localEndpoint?.serviceName) }">
- {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
- </dd>
- <dt>{{ t('Name') }}</dt><dd class="mono wba">{{ selectedSpan.name
?? '—' }}</dd>
- <dt>{{ t('Kind') }}</dt><dd>{{ selectedSpan.kind ?? '—' }}</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">{{
fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
- <dt>{{ t('Duration') }}</dt><dd class="mono">{{
fmtMs(selectedSpan.duration ?? 0) }}</dd>
- <dt v-if="selectedSpan.remoteEndpoint?.serviceName">{{ t('Peer')
}}</dt>
- <dd v-if="selectedSpan.remoteEndpoint?.serviceName" class="mono
wba">
- {{ selectedSpan.remoteEndpoint.serviceName }}
- </dd>
- </dl>
- <section v-if="selectedSpan.tags &&
Object.keys(selectedSpan.tags).length > 0" class="zk-tags">
- <h6>{{ t('Tags') }}</h6>
- <dl class="zk-kv">
- <template v-for="(v, k) in selectedSpan.tags" :key="k">
- <dt class="mono">{{ k }}</dt>
- <dd class="mono wba">{{ v }}</dd>
- </template>
- </dl>
- </section>
- <section
- v-if="selectedSpan.annotations && selectedSpan.annotations.length
> 0"
- class="zk-annotations"
- >
- <h6>{{ t('Annotations') }}</h6>
- <ul>
- <li v-for="(a, i) in selectedSpan.annotations" :key="i"
class="mono">
- <span class="dim">{{ fmtAbsTime(a.timestamp) }}</span>
- <span class="zk-ann-val">{{ a.value }}</span>
- </li>
- </ul>
- </section>
- </aside>
+ <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:
serviceColor(selectedSpan.localEndpoint?.serviceName) }">
+ <span class="svc-swatch inline" :style="{ background:
serviceColor(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>
</article>
</div>
@@ -345,7 +402,7 @@ function copyTraceId(): void {
display: flex;
align-items: center;
justify-content: center;
- z-index: 100;
+ z-index: 999; /* match native TracePopout; the span-modal (1000) nests above
*/
padding: 24px;
}
.zk-popout {
@@ -373,78 +430,65 @@ function copyTraceId(): void {
font-size: 12px;
}
-.zk-split {
- position: relative;
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
.zk-waterfall {
height: 100%;
+ overflow-x: hidden;
overflow-y: auto;
- padding: 0;
+ padding: 4px 0;
}
-.zk-time-axis {
+.tp-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; }
+.tp-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;
- /* Solid background + shadow so the axis cleanly overlays the
- * scrolling rows underneath rather than letting the first row's
- * bar bleed through (sibling rendering on scroll). */
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);
- font-family: var(--sw-mono);
-}
-.zk-row {
- display: grid;
- grid-template-columns: 280px 1fr;
- align-items: center;
- gap: 8px;
- padding: 3px 12px;
- border-bottom: 1px solid var(--sw-line);
- cursor: pointer;
-}
-.zk-row:hover { background: var(--sw-bg-2); }
-.zk-row.on { background: var(--sw-bg-3); }
-.zk-row.err { box-shadow: inset 3px 0 0 var(--sw-err); }
-.zk-row-label {
- display: inline-flex;
- align-items: baseline;
- gap: 6px;
- font-size: 10.5px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
-}
-.zk-row-svc {
- flex: 0 0 auto;
- font-weight: 700;
- max-width: 100px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.zk-row-name {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- color: var(--sw-fg-1);
-}
-.zk-track {
- position: relative;
- height: 18px;
- background: var(--sw-bg-1);
- border-radius: 2px;
- overflow: visible;
}
+.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;
@@ -457,81 +501,180 @@ function copyTraceId(): void {
.t-grid.q1 { left: 25%; }
.t-grid.q2 { left: 50%; }
.t-grid.q3 { left: 75%; }
-.zk-bar {
+.tp-bar {
position: absolute;
top: 2px;
bottom: 2px;
- border-radius: 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;
}
-.zk-dur {
+.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;
}
-
-.zk-detail {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- width: min(640px, 60%);
+.status-flag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 18px;
+ padding: 0 6px;
+ border-radius: 9px;
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ flex: 0 0 auto;
+}
+.status-flag.sm { height: 12px; width: 12px; padding: 0; border-radius: 50%;
justify-content: center; }
+.status-flag.sm .flag-dot { margin: 0; }
+.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); }
+.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;
- padding: 12px 14px;
- background: var(--sw-bg-1);
- border-left: 1px solid var(--sw-line);
- box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
- z-index: 5;
}
-.zk-detail-head {
+.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;
- justify-content: space-between;
- margin-bottom: 8px;
+ gap: 12px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ flex: 0 0 auto;
}
-.zk-detail-head h5 {
+.span-modal-head h4 {
margin: 0;
font-size: 12px;
- color: var(--sw-fg-0);
+ font-weight: 600;
+ display: inline-flex;
+ gap: 10px;
+ align-items: baseline;
+ flex: 1;
+ min-width: 0;
}
-.zk-tags, .zk-annotations { margin-top: 12px; }
-.zk-tags h6, .zk-annotations h6 {
+.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: 10px;
+ font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
- color: var(--sw-fg-3);
- font-weight: 600;
+ color: var(--sw-accent);
+ font-weight: 700;
}
-.zk-kv {
+.kv {
display: grid;
- grid-template-columns: max-content 1fr;
- gap: 4px 12px;
+ grid-template-columns: 100px 1fr;
+ gap: 4px 10px;
+ margin: 0;
font-size: 11px;
}
-.zk-kv dt { color: var(--sw-fg-3); }
-.zk-kv dd { margin: 0; color: var(--sw-fg-1); }
-.zk-kv dd.wba { word-break: break-all; }
-.zk-annotations ul {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 4px;
+.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);
}
-.zk-annotations li {
- display: grid;
- grid-template-columns: 110px 1fr;
- gap: 8px;
- font-size: 10.5px;
+.span-event-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 11px;
}
-.zk-annotations .dim { color: var(--sw-fg-3); }
-.zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
+.ann-hint { color: var(--sw-fg-3); }
+.dim { color: var(--sw-fg-3); }
+.wba { word-break: break-all; }
.mono { font-family: var(--sw-mono); }
.kicker {
font-size: 10px;
@@ -540,17 +683,6 @@ function copyTraceId(): void {
color: var(--sw-accent);
font-weight: 600;
}
-.sw-btn.primary {
- background: var(--sw-accent);
- color: var(--sw-bg-0);
- border: none;
- height: 26px;
- padding: 0 14px;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 600;
- cursor: pointer;
-}
.sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; }
.sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2);
color: var(--sw-fg-2); }
</style>
diff --git a/apps/ui/src/layer/traces/useTracePopout.ts
b/apps/ui/src/layer/traces/useTracePopout.ts
index bf80eac..3b44340 100644
--- a/apps/ui/src/layer/traces/useTracePopout.ts
+++ b/apps/ui/src/layer/traces/useTracePopout.ts
@@ -32,6 +32,7 @@
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
+import { useTraceSourceIsZipkin } from './useZipkinTracePopout';
/** Query-string keys. Any URL with TRACE_POPOUT_QUERY auto-opens the
* trace popout — shareable. TRACE_POPOUT_AT carries the trace's
@@ -48,9 +49,10 @@ export function useTracePopout() {
const route = useRoute();
const router = useRouter();
+ const sourceIsZipkin = useTraceSourceIsZipkin();
const openTraceId = computed<string | null>(() => {
const v = route.query[TRACE_POPOUT_QUERY];
- return typeof v === 'string' && v.length > 0 ? v : null;
+ return typeof v === 'string' && v.length > 0 && !sourceIsZipkin.value ? v
: null;
});
const openTraceAtMs = computed<number | null>(() => {
const v = route.query[TRACE_POPOUT_AT];
diff --git a/apps/ui/src/layer/traces/useZipkinTracePopout.ts
b/apps/ui/src/layer/traces/useZipkinTracePopout.ts
index a44244f..0e6f30e 100644
--- a/apps/ui/src/layer/traces/useZipkinTracePopout.ts
+++ b/apps/ui/src/layer/traces/useZipkinTracePopout.ts
@@ -15,34 +15,45 @@
* limitations under the License.
*/
-/**
- * URL-backed popout state for Zipkin traces. Mirrors `useTracePopout`
- * (the native popout) but reads/writes `?openZipkinTraceId=<id>` so
- * the two trace kinds can be open simultaneously without colliding.
- */
+/** URL-backed popout state for Zipkin traces, sharing the native
+ * popout's `?traceId=` param. */
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
+import { useLayers } from '@/shell/useLayers';
+
+// Native vs Zipkin keys on the layer's trace source (route), not the
+// ID shape — native IDs can be bare hex, same as Zipkin.
+export function useTraceSourceIsZipkin() {
+ const route = useRoute();
+ const { layers } = useLayers();
+ return computed<boolean>(() => {
+ if (/\/zipkin-trace(\/|$|\?)/.test(route.path)) return true;
+ const key = String(route.params.layerKey ?? '');
+ return (layers.value.find((l) => l.key === key)?.traces?.source ??
'native') === 'zipkin';
+ });
+}
export function useZipkinTracePopout() {
const route = useRoute();
const router = useRouter();
+ const sourceIsZipkin = useTraceSourceIsZipkin();
const openTraceId = computed<string | null>(() => {
- const v = route.query.openZipkinTraceId;
- return typeof v === 'string' && v.length > 0 ? v : null;
+ const v = route.query.traceId;
+ return typeof v === 'string' && v.length > 0 && sourceIsZipkin.value ? v :
null;
});
function openTrace(id: string): void {
if (!id) return;
- const next = { ...route.query, openZipkinTraceId: id };
- void router.replace({ path: route.path, query: next });
+ void router.replace({ path: route.path, query: { ...route.query, traceId:
id } });
}
function closeTrace(): void {
if (!openTraceId.value) return;
const next = { ...route.query };
- delete next.openZipkinTraceId;
+ delete next.traceId;
+ delete next.traceAt;
void router.replace({ path: route.path, query: next });
}
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index c5bc335..4ab0abc 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -173,10 +173,8 @@ function startSidebarResize(e: PointerEvent): void {
<!-- Global trace-id popout: any page can call
useTracePopout().openTrace(id)
and this modal renders the waterfall + span detail. -->
<TracePopout />
- <!-- Zipkin trace popout — separate URL key (`?openZipkinTraceId=`)
- so the native + Zipkin popouts can be open in parallel without
- collision (e.g. an operator drilling into a Zipkin trace from
- a Logs row → trace link on a mesh layer). -->
+ <!-- Zipkin trace popout — shares `?traceId=`; native vs Zipkin
+ self-select by ID shape (see isZipkinTraceId). -->
<ZipkinTracePopout />
<!-- Per-session prompt: when local template edits diverge from OAP,
ask once which version to render (local preview vs remote live). -->