From 825dabddc4bcf2d2fd2e8799dd26ae1098fc549a Mon Sep 17 00:00:00 2001 From: dlwldn30 Date: Wed, 26 Aug 2026 22:53:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=94=A8=20Trace=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=97=B0=EA=B2=B0=EC=84=A0=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/workbench/views/TraceView.tsx | 2 +- frontend/src/lib/graph.test.tsx | 19 ++++- frontend/src/lib/graph.tsx | 85 +++++++++++++------ 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/frontend/src/features/workbench/views/TraceView.tsx b/frontend/src/features/workbench/views/TraceView.tsx index df58ae1..d6f59ee 100644 --- a/frontend/src/features/workbench/views/TraceView.tsx +++ b/frontend/src/features/workbench/views/TraceView.tsx @@ -195,7 +195,7 @@ export function TraceView({ model }: TraceViewProps) { nodesConnectable={false} nodesDraggable={false} elementsSelectable - minZoom={0.35} + minZoom={0.15} maxZoom={1.35} proOptions={{ hideAttribution: true }} > diff --git a/frontend/src/lib/graph.test.tsx b/frontend/src/lib/graph.test.tsx index f5eda56..47d6f6f 100644 --- a/frontend/src/lib/graph.test.tsx +++ b/frontend/src/lib/graph.test.tsx @@ -89,8 +89,25 @@ describe('buildGraph', () => { expect(graph.nodes.map((node) => node.id)).toEqual(['root-span', 'child-span']) expect(graph.edges).toEqual(expect.arrayContaining([ - expect.objectContaining({ source: 'root-span', target: 'child-span' }), + expect.objectContaining({ source: 'root-span', target: 'child-span', zIndex: 0 }), ])) + expect(graph.nodes[0]).toMatchObject({ sourcePosition: 'right', targetPosition: 'left' }) + expect(graph.nodes[1].position.x).toBeGreaterThan(graph.nodes[0].position.x) + }) + + it('centers a parent between child branches without sharing a node row', () => { + const graph = buildGraph(trace('OPENTELEMETRY', [ + event({ eventId: 'root', spanId: 'root-span', spanKind: 'SERVER' }), + event({ eventId: 'child-a', spanId: 'child-a', parentSpanId: 'root-span' }), + event({ eventId: 'child-b', spanId: 'child-b', parentSpanId: 'root-span' }), + ])) + + const root = graph.nodes.find((node) => node.id === 'root-span') + const childA = graph.nodes.find((node) => node.id === 'child-a') + const childB = graph.nodes.find((node) => node.id === 'child-b') + + expect(childA?.position.y).not.toBe(childB?.position.y) + expect(root?.position.y).toBe(((childA?.position.y ?? 0) + (childB?.position.y ?? 0)) / 2) }) it('marks an OpenTelemetry edge into a failed child span as failed', () => { diff --git a/frontend/src/lib/graph.tsx b/frontend/src/lib/graph.tsx index 8c05cee..a547674 100644 --- a/frontend/src/lib/graph.tsx +++ b/frontend/src/lib/graph.tsx @@ -1,4 +1,4 @@ -import { MarkerType } from '@xyflow/react' +import { MarkerType, Position } from '@xyflow/react' import type { Edge, Node } from '@xyflow/react' import type { ComponentType, EventStatus, GraphNodeState, TraceDetail, TraceEvent } from '../types/trace' @@ -168,23 +168,7 @@ function buildSpanGraph(events: TraceEvent[]): { } { const spanEvents = sortEventsByStartTime(events).filter((event) => event.spanId) const eventsBySpanId = new Map(spanEvents.map((event) => [event.spanId as string, event])) - const depthCache = new Map() - - const getDepth = (spanId: string, visited = new Set()): number => { - const cached = depthCache.get(spanId) - if (cached !== undefined) return cached - if (visited.has(spanId)) return 0 - - const event = eventsBySpanId.get(spanId) - const parentId = event?.parentSpanId - const depth = parentId && eventsBySpanId.has(parentId) - ? getDepth(parentId, new Set(visited).add(spanId)) + 1 - : 0 - depthCache.set(spanId, depth) - return depth - } - - const rowByDepth = new Map() + const positions = buildSpanTreePositions(spanEvents, eventsBySpanId) const states = spanEvents.map((event) => ({ id: event.spanId as string, component: event.component, @@ -197,12 +181,11 @@ function buildSpanGraph(events: TraceEvent[]): { const nodes: Node[] = states.map((state) => { const event = state.visits[0] - const depth = getDepth(state.id) - const row = rowByDepth.get(depth) ?? 0 - rowByDepth.set(depth, row + 1) return { id: state.id, - position: { x: depth * 270, y: row * 160 }, + position: positions.get(state.id) ?? { x: 0, y: 0 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, data: { label: (
@@ -241,14 +224,68 @@ function buildSpanGraph(events: TraceEvent[]): { height: 18, color: failed ? '#c2413c' : '#1f7a55', }, - zIndex: 8, - interactionWidth: 24, + zIndex: 0, + interactionWidth: 16, }] }) return { nodes, edges, states } } +function buildSpanTreePositions( + events: TraceEvent[], + eventsBySpanId: Map, +): Map { + const horizontalGap = 280 + const verticalGap = 170 + const childrenByParent = new Map() + + for (const event of events) { + if (!event.spanId || !event.parentSpanId || !eventsBySpanId.has(event.parentSpanId)) continue + const children = childrenByParent.get(event.parentSpanId) ?? [] + children.push(event.spanId) + childrenByParent.set(event.parentSpanId, children) + } + + const roots = events + .filter((event) => event.spanId && (!event.parentSpanId || !eventsBySpanId.has(event.parentSpanId))) + .map((event) => event.spanId as string) + const positions = new Map() + const positioned = new Set() + let nextLeafRow = 0 + + const placeSpan = (spanId: string, depth: number, ancestors: Set): number => { + const existing = positions.get(spanId) + if (existing) return existing.y + + if (ancestors.has(spanId)) { + const y = nextLeafRow * verticalGap + nextLeafRow += 1 + positions.set(spanId, { x: depth * horizontalGap, y }) + positioned.add(spanId) + return y + } + + const children = (childrenByParent.get(spanId) ?? []).filter((childId) => !positioned.has(childId)) + const nextAncestors = new Set(ancestors).add(spanId) + const childRows = children.map((childId) => placeSpan(childId, depth + 1, nextAncestors)) + const y = childRows.length > 0 + ? childRows.reduce((sum, childY) => sum + childY, 0) / childRows.length + : nextLeafRow++ * verticalGap + + positions.set(spanId, { x: depth * horizontalGap, y }) + positioned.add(spanId) + return y + } + + roots.forEach((spanId) => placeSpan(spanId, 0, new Set())) + events.forEach((event) => { + if (event.spanId && !positioned.has(event.spanId)) placeSpan(event.spanId, 0, new Set()) + }) + + return positions +} + export function getNodeDetail(states: GraphNodeState[], nodeId: string | null): GraphNodeState | null { if (!nodeId) { return null