From 24e4860fa5e54bb8c6c08387c3c21b8f30b19e68 Mon Sep 17 00:00:00 2001 From: dlwldn30 Date: Sun, 30 Aug 2026 15:18:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Trace=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=EC=99=80=20=EC=A7=84=EB=8B=A8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 12 ++- .../features/workbench/StackFlowWorkbench.tsx | 4 +- .../workbench/components/AppShell.test.tsx | 31 ++++-- .../workbench/components/AppShell.tsx | 20 ++-- .../workbench/hooks/useWorkbenchController.ts | 42 +++----- .../src/features/workbench/traceModel.test.ts | 21 +++- frontend/src/features/workbench/traceModel.ts | 76 ++++++++++++++- .../workbench/views/TraceOutcomeSummary.tsx | 67 +++++++------ .../features/workbench/views/TraceView.css | 96 +++++++++++++------ .../features/workbench/views/TraceView.tsx | 7 +- .../workbench/views/traceWorkspace.test.tsx | 11 ++- .../features/workbench/views/views.test.tsx | 4 +- 12 files changed, 252 insertions(+), 139 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 3a4a11b..329ec02 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -89,7 +89,7 @@ summary:focus-visible { .topbar__trace-meta { display: flex; align-items: center; - gap: 18px; + gap: 12px; } .topbar__trace-meta > span { @@ -107,6 +107,10 @@ summary:focus-visible { font: 650 0.78rem/1.3 var(--font-mono); } +.topbar__trace-meta .status-badge { + min-height: 28px; +} + .workflow-tabs { position: sticky; top: var(--topbar-height); @@ -1487,7 +1491,7 @@ summary:focus-visible { justify-self: end; } -.topbar__trace-meta { + .topbar__trace-meta { grid-column: 1 / -1; justify-content: space-between; gap: 8px; @@ -1495,6 +1499,10 @@ summary:focus-visible { padding-top: 8px; } + .topbar__trace-meta > .status-badge:only-child { + margin-left: auto; + } + .workflow-tabs { position: sticky; top: 0; diff --git a/frontend/src/features/workbench/StackFlowWorkbench.tsx b/frontend/src/features/workbench/StackFlowWorkbench.tsx index a038e02..813da4c 100644 --- a/frontend/src/features/workbench/StackFlowWorkbench.tsx +++ b/frontend/src/features/workbench/StackFlowWorkbench.tsx @@ -17,9 +17,7 @@ export function StackFlowWorkbench() { hasDetectedApis={shell.hasDetectedApis} requestReady={shell.requestReady} traceId={shell.traceId} - traceResultStatus={shell.traceResultStatus} - traceDisplayStatus={shell.traceDisplayStatus} - traceEventCount={shell.traceEventCount} + traceCollectionPresentation={shell.traceCollectionPresentation} onViewChange={shell.setActiveView} > {shell.activeView === 'project' ? : null} diff --git a/frontend/src/features/workbench/components/AppShell.test.tsx b/frontend/src/features/workbench/components/AppShell.test.tsx index 0f88209..e944448 100644 --- a/frontend/src/features/workbench/components/AppShell.test.tsx +++ b/frontend/src/features/workbench/components/AppShell.test.tsx @@ -13,9 +13,7 @@ describe('AppShell', () => { hasDetectedApis requestReady traceId={null} - traceResultStatus="IDLE" - traceDisplayStatus="대기" - traceEventCount={0} + traceCollectionPresentation={{ label: 'Trace 대기', tone: 'neutral' }} onViewChange={vi.fn()} >
작업 화면
@@ -39,9 +37,7 @@ describe('AppShell', () => { hasDetectedApis requestReady={false} traceId={null} - traceResultStatus="IDLE" - traceDisplayStatus="대기" - traceEventCount={0} + traceCollectionPresentation={{ label: 'Trace 대기', tone: 'neutral' }} onViewChange={vi.fn()} >
API 화면
@@ -51,4 +47,27 @@ describe('AppShell', () => { expect(within(container).getAllByText('정적 분석만')).toHaveLength(2) expect(within(container).getByRole('button', { name: /API 요청/ })).toHaveAttribute('aria-current', 'page') }) + + it('shows only the trace identity and collection lifecycle in the runtime header', () => { + render( + +
Trace 화면
+
, + ) + + expect(screen.getByText('12345678')).toBeInTheDocument() + expect(screen.getByText('Span 수집 시간 초과')).toBeInTheDocument() + expect(screen.queryByText('결과')).not.toBeInTheDocument() + expect(screen.queryByText('이벤트')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/features/workbench/components/AppShell.tsx b/frontend/src/features/workbench/components/AppShell.tsx index 11a0e5a..6e1945f 100644 --- a/frontend/src/features/workbench/components/AppShell.tsx +++ b/frontend/src/features/workbench/components/AppShell.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react' import { StatusBadge } from '../../../components/StatusBadge' +import type { StatusTone } from '../../../components/StatusBadge' import { WorkflowTabs } from '../../../components/WorkflowTabs' import { getWorkflowNavigationState } from '../../../components/workflowNavigation' -import { getResultStatusLabel } from '../../../ui/copy' -import type { EventStatus, ProjectAnalysisStatus } from '../../../types/trace' +import type { ProjectAnalysisStatus } from '../../../types/trace' import type { AnalysisTarget } from '../types' import type { ViewMode } from '../../../ui/copy' @@ -15,9 +15,7 @@ type AppShellProps = { hasDetectedApis: boolean requestReady: boolean traceId: string | null - traceResultStatus: EventStatus | 'IDLE' - traceDisplayStatus: string - traceEventCount: number + traceCollectionPresentation: { label: string; tone: StatusTone } onViewChange: (view: ViewMode) => void children: ReactNode } @@ -30,9 +28,7 @@ export function AppShell({ hasDetectedApis, requestReady, traceId, - traceResultStatus, - traceDisplayStatus, - traceEventCount, + traceCollectionPresentation, onViewChange, children, }: AppShellProps) { @@ -55,10 +51,10 @@ export function AppShell({ {activeView === 'runtime' ? (
- Trace ID{traceId?.slice(0, 8) ?? '대기'} - 결과{getResultStatusLabel(traceResultStatus)} - 상태{traceDisplayStatus} - 이벤트{traceEventCount} + {traceId ? Trace ID{traceId.slice(0, 8)} : null} + + {traceCollectionPresentation.label} +
) : ( diff --git a/frontend/src/features/workbench/hooks/useWorkbenchController.ts b/frontend/src/features/workbench/hooks/useWorkbenchController.ts index bda5e93..3cf5de2 100644 --- a/frontend/src/features/workbench/hooks/useWorkbenchController.ts +++ b/frontend/src/features/workbench/hooks/useWorkbenchController.ts @@ -1,13 +1,10 @@ import { useEffect, useMemo, useState } from 'react' import { buildGraph, getNodeDetail } from '../../../lib/graph' import { buildWaterfall, getPrimaryFailureEvent } from '../../../lib/waterfall' -import type { EventStatus } from '../../../types/trace' -import { EVENT_STATUS_LABEL, STREAM_STATUS_LABEL, TRACE_COLLECTION_STATUS_LABEL } from '../../../ui/copy' -import type { StatusTone } from '../../../components/StatusBadge' import type { ViewMode } from '../../../ui/copy' import { EMPTY_API_DEFINITION, EMPTY_DOMAIN, FALLBACK_API_CATALOG, FALLBACK_PROJECT_STRUCTURE, PROJECT_STATUS_CONTENT } from '../fixtures' import { createRequestEntry, filterApis, formatResponseBody } from '../requestModel' -import { buildFailurePropagationPath, filterTraceHistory, getDefaultInspectionEvent, getInspectorEvent, getTraceOutcome } from '../traceModel' +import { buildFailurePropagationPath, buildTraceOutcomePresentation, filterTraceHistory, getDefaultInspectionEvent, getInspectorEvent, getTraceCollectionPresentation } from '../traceModel' import { buildCommonProjectLayers, buildDomainStructurePath, @@ -90,8 +87,9 @@ export function useWorkbenchController() { ?? graph.states.find((state) => state.id === primaryFailureNodeId)?.label ?? primaryFailureEvent?.component ?? null - const traceOutcome = runtime.traceDetail ? getTraceOutcome(runtime.traceDetail, primaryFailureEvent) : null - const traceCollectionTimedOut = runtime.traceDetail?.traceCollectionStatus === 'TIMED_OUT' + const traceOutcome = runtime.traceDetail + ? buildTraceOutcomePresentation(runtime.traceDetail, primaryFailureEvent) + : null const failurePropagationPath = buildFailurePropagationPath(runtime.traceDetail?.events ?? [], primaryFailureEvent) const filteredRecentTraces = filterTraceHistory(runtime.recentTraces, runtime.traceHistoryFilter) const projectMetrics = buildProjectMetrics(project.projectStructure) @@ -138,24 +136,11 @@ export function useWorkbenchController() { ? '요청 후 Trace 확인' : externalTraceConfigured ? 'Trace 설정됨 · 확인 전' : '외부 API 요청' : '정적 분석만 가능' - const traceDisplayStatus = runtime.streamStatus === 'connection_timeout' - ? STREAM_STATUS_LABEL[runtime.streamStatus] - : traceCollectionTimedOut - ? TRACE_COLLECTION_STATUS_LABEL.TIMED_OUT - : runtime.traceDetail?.source === 'OPENTELEMETRY' && runtime.streamStatus !== 'idle' - ? TRACE_COLLECTION_STATUS_LABEL[runtime.traceCollectionStatus] - : runtime.traceDetail - ? EVENT_STATUS_LABEL[runtime.traceDetail.resultStatus] - : STREAM_STATUS_LABEL[runtime.streamStatus] - const traceDisplayTone: StatusTone = traceCollectionTimedOut - ? 'warning' - : runtime.streamStatus === 'idle' && runtime.traceDetail - ? runtime.traceDetail.resultStatus === 'SUCCESS' ? 'success' - : runtime.traceDetail.resultStatus === 'WARNING' ? 'warning' : 'error' - : runtime.streamStatus === 'completed' ? 'success' - : runtime.streamStatus === 'error' ? 'error' - : runtime.streamStatus === 'connection_timeout' ? 'warning' - : runtime.streamStatus === 'idle' ? 'neutral' : 'info' + const traceCollectionPresentation = getTraceCollectionPresentation( + runtime.streamStatus, + runtime.traceDetail?.traceCollectionStatus ?? runtime.traceCollectionStatus, + Boolean(runtime.traceDetail), + ) const selectedApiMethodLabel = getApiMethodLabel(selectedApi) const selectedApiMethodClassName = getApiMethodBadgeClassName(selectedApi) const formattedResponseBody = useMemo(() => request.lastResponseBody @@ -167,9 +152,6 @@ export function useWorkbenchController() { const graphFitKey = `${runtime.traceDetail?.traceId ?? 'empty'}-${runtime.traceDetail?.events.length ?? 0}` const selectedTraceId = runtime.traceDetail?.traceId const setSelectedNodeId = runtime.setSelectedNodeId - const currentResultStatus: EventStatus | 'IDLE' = request.externalResponse?.resultStatus - ?? runtime.traceDetail?.resultStatus - ?? 'IDLE' const effectiveProjectStatus = project.analysisState === 'error' || project.analysisResultState === 'none' ? 'FAILED' : project.projectStructure.analysisStatus @@ -210,9 +192,7 @@ export function useWorkbenchController() { hasDetectedApis: hasUsableApis, requestReady: hasUsableApis && hasConcreteMethod, traceId: runtime.traceDetail?.traceId ?? null, - traceResultStatus: currentResultStatus, - traceDisplayStatus, - traceEventCount: runtime.traceDetail?.events.length ?? 0, + traceCollectionPresentation, }, projectView: { ...project, ...projectActions, @@ -243,7 +223,7 @@ export function useWorkbenchController() { selectedNode, inspectorEvent, primaryFailureLabel, traceOutcome, failurePropagationPath, filteredRecentTraces, activeRoute, traceComparison, traceServiceGroups, - traceDisplayTone, traceDisplayStatus, selectedApi, selectedDomain, + selectedApi, selectedDomain, analysisTarget: project.analysisTarget, runtimeSupported, externalTraceConfigured, selectedApiMethodLabel, }, diff --git a/frontend/src/features/workbench/traceModel.test.ts b/frontend/src/features/workbench/traceModel.test.ts index e535164..9c35821 100644 --- a/frontend/src/features/workbench/traceModel.test.ts +++ b/frontend/src/features/workbench/traceModel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { ComponentType, EventStatus, GraphNodeState, TraceDetail, TraceEvent, TraceSummary } from '../../types/trace' -import { buildFailurePropagationPath, filterTraceHistory, getDefaultInspectionEvent, getExceptionLocation, getInspectorEvent, getKeyMetadata, getTraceOutcome, MAX_RECENT_TRACES, resolveCodeLocation, upsertRecentTrace } from './traceModel' +import { buildFailurePropagationPath, buildTraceOutcomePresentation, filterTraceHistory, getDefaultInspectionEvent, getExceptionLocation, getInspectorEvent, getKeyMetadata, getTraceCollectionPresentation, MAX_RECENT_TRACES, resolveCodeLocation, upsertRecentTrace } from './traceModel' function event( spanId: string, @@ -43,17 +43,28 @@ function trace(resultStatus: EventStatus, events: TraceEvent[]): TraceDetail { describe('trace inspection model', () => { it('classifies an internal Redis error followed by success as recovered', () => { const redis = event('redis', 'server', 'REDIS', 'ERROR') - expect(getTraceOutcome(trace('SUCCESS', [redis]), redis)).toBe('recovered') + expect(buildTraceOutcomePresentation(trace('SUCCESS', [redis]), redis).outcome).toBe('recovered') }) it('classifies a terminal timeout as failure', () => { const postgres = event('postgres', 'server', 'POSTGRESQL', 'TIMEOUT') - expect(getTraceOutcome(trace('TIMEOUT', [postgres]), postgres)).toBe('failure') + expect(buildTraceOutcomePresentation(trace('TIMEOUT', [postgres]), postgres).outcome).toBe('failure') }) - it('classifies span collection timeout separately from the HTTP result', () => { + it('keeps a successful HTTP result when span collection times out', () => { const timedOut = { ...trace('SUCCESS', []), traceCollectionStatus: 'TIMED_OUT' as const } - expect(getTraceOutcome(timedOut, null)).toBe('collection_timeout') + expect(buildTraceOutcomePresentation(timedOut, null)).toMatchObject({ + outcome: 'success', + resultLabel: 'HTTP 요청 성공', + collectionTimedOut: true, + }) + }) + + it('keeps collection lifecycle labels independent from the HTTP result', () => { + expect(getTraceCollectionPresentation('streaming', 'COLLECTING', true)).toEqual({ label: '수집 중', tone: 'info' }) + expect(getTraceCollectionPresentation('error', 'TIMED_OUT', true)).toEqual({ label: 'Span 수집 시간 초과', tone: 'warning' }) + expect(getTraceCollectionPresentation('idle', 'COMPLETED', true)).toEqual({ label: '수집 완료', tone: 'success' }) + expect(getTraceCollectionPresentation('idle', 'DISABLED', false)).toEqual({ label: 'Trace 대기', tone: 'neutral' }) }) it('selects the server span for a successful trace', () => { diff --git a/frontend/src/features/workbench/traceModel.ts b/frontend/src/features/workbench/traceModel.ts index ea14950..1746c92 100644 --- a/frontend/src/features/workbench/traceModel.ts +++ b/frontend/src/features/workbench/traceModel.ts @@ -1,9 +1,24 @@ import type { GraphNodeState, TraceDetail, TraceEvent, TraceResponsePreview, TraceSummary } from '../../types/trace' +import type { StatusTone } from '../../components/StatusBadge' +import type { StreamStatus } from '../../ui/copy' export type TraceHistoryFilter = 'all' | 'success' | 'attention' | 'timeout' -export type TraceOutcome = 'success' | 'recovered' | 'failure' | 'collection_timeout' +export type TraceOutcome = 'success' | 'recovered' | 'failure' export const MAX_RECENT_TRACES = 25 +export interface TraceOutcomePresentation { + outcome: TraceOutcome + resultLabel: string + resultDetail: string + collectionTimedOut: boolean + serviceTransitions: number +} + +export interface TraceCollectionPresentation { + label: string + tone: StatusTone +} + export interface TraceMetadataItem { key: string label: string @@ -54,10 +69,61 @@ export function formatTraceResponsePreview(preview: TraceResponsePreview | null) return preview.body } -export function getTraceOutcome(trace: TraceDetail, failureEvent: TraceEvent | null): TraceOutcome { - if (trace.traceCollectionStatus === 'TIMED_OUT') return 'collection_timeout' - if (trace.resultStatus === 'ERROR' || trace.resultStatus === 'TIMEOUT') return 'failure' - return failureEvent ? 'recovered' : 'success' +export function buildTraceOutcomePresentation( + trace: TraceDetail, + failureEvent: TraceEvent | null, +): TraceOutcomePresentation { + const outcome: TraceOutcome = trace.resultStatus === 'ERROR' || trace.resultStatus === 'TIMEOUT' + ? 'failure' + : failureEvent ? 'recovered' : 'success' + const collectionTimedOut = trace.traceCollectionStatus === 'TIMED_OUT' + const bySpanId = new Map(trace.events.filter((event) => event.spanId).map((event) => [event.spanId as string, event])) + const serviceTransitions = trace.events.filter((event) => { + const parent = event.parentSpanId ? bySpanId.get(event.parentSpanId) : null + return Boolean(parent?.serviceName && event.serviceName && parent.serviceName !== event.serviceName) + }).length + + if (outcome === 'failure') { + return { + outcome, + resultLabel: '요청 실패', + resultDetail: '실제 하위 원인 Span부터 확인하세요.', + collectionTimedOut, + serviceTransitions, + } + } + if (outcome === 'recovered') { + return { + outcome, + resultLabel: '복구된 실패', + resultDetail: '중간 오류가 발생했지만 fallback으로 요청은 완료됐습니다.', + collectionTimedOut, + serviceTransitions, + } + } + return { + outcome, + resultLabel: collectionTimedOut ? 'HTTP 요청 성공' : '정상 완료', + resultDetail: collectionTimedOut + ? 'HTTP 요청은 완료됐지만 전체 Span 수집 여부는 확인할 수 없습니다.' + : '실패 없이 요청 처리가 끝났습니다.', + collectionTimedOut, + serviceTransitions, + } +} + +export function getTraceCollectionPresentation( + streamStatus: StreamStatus, + collectionStatus: TraceDetail['traceCollectionStatus'], + hasTrace: boolean, +): TraceCollectionPresentation { + if (collectionStatus === 'TIMED_OUT') return { label: 'Span 수집 시간 초과', tone: 'warning' } + if (collectionStatus === 'COMPLETED' || streamStatus === 'completed') return { label: '수집 완료', tone: 'success' } + if (streamStatus === 'connection_timeout' || streamStatus === 'error') return { label: '실시간 연결 실패', tone: 'error' } + if (collectionStatus === 'COLLECTING' || streamStatus === 'streaming') return { label: '수집 중', tone: 'info' } + if (collectionStatus === 'PENDING' || streamStatus === 'connecting') return { label: 'Span 대기', tone: 'info' } + if (hasTrace) return { label: '수집 완료', tone: 'success' } + return { label: 'Trace 대기', tone: 'neutral' } } export function getDefaultInspectionEvent( diff --git a/frontend/src/features/workbench/views/TraceOutcomeSummary.tsx b/frontend/src/features/workbench/views/TraceOutcomeSummary.tsx index 6b0e7f7..dd3175c 100644 --- a/frontend/src/features/workbench/views/TraceOutcomeSummary.tsx +++ b/frontend/src/features/workbench/views/TraceOutcomeSummary.tsx @@ -1,48 +1,41 @@ -import { AlertCircle, CheckCircle2, ChevronRight, RotateCcw } from 'lucide-react' +import { AlertCircle, CheckCircle2, ChevronRight, Radio, RotateCcw } from 'lucide-react' import type { TraceDetail, TraceEvent } from '../../../types/trace' import { EVENT_STATUS_LABEL } from '../../../ui/copy' -import type { TraceOutcome } from '../traceModel' +import type { TraceOutcomePresentation } from '../traceModel' interface TraceOutcomeSummaryProps { trace: TraceDetail - outcome: TraceOutcome + presentation: TraceOutcomePresentation failureEvent: TraceEvent | null failureLabel: string | null propagationPath: TraceEvent[] onInspectFailure: () => void } -const OUTCOME_COPY: Record = { - success: { label: '정상 완료', detail: '실패 없이 요청 처리가 끝났습니다.' }, - recovered: { label: '복구된 실패', detail: '중간 오류가 발생했지만 fallback으로 요청은 완료됐습니다.' }, - failure: { label: '요청 실패', detail: '실제 하위 원인 span부터 확인하세요.' }, - collection_timeout: { label: 'Span 수집 시간 초과', detail: 'HTTP 요청 결과는 유지됐지만 Agent span 수집이 완료되지 않았습니다.' }, -} - export function TraceOutcomeSummary({ trace, - outcome, + presentation, failureEvent, failureLabel, propagationPath, onInspectFailure, }: TraceOutcomeSummaryProps) { - const copy = OUTCOME_COPY[outcome] - const bySpanId = new Map(trace.events.filter((event) => event.spanId).map((event) => [event.spanId as string, event])) - const serviceTransitions = trace.events.filter((event) => { - const parent = event.parentSpanId ? bySpanId.get(event.parentSpanId) : null - return Boolean(parent?.serviceName && event.serviceName && parent.serviceName !== event.serviceName) - }).length + const { outcome, resultLabel, resultDetail, collectionTimedOut, serviceTransitions } = presentation + const propagationLabel = propagationPath + .map((event) => `${event.serviceName ? `${event.serviceName} / ` : ''}${event.eventType}`) + .join(' → ') return (
-
- 결과{copy.label} - HTTP{trace.httpStatus || '-'} +
+ 실행 결과{resultLabel} + HTTP 상태{trace.httpStatus || '-'} 총 소요 시간{trace.durationMs}ms - Span{trace.events.length}개 +
+
진입 서비스{trace.serviceName ?? '-'} 참여 서비스{trace.serviceNames.length}개 · 경계 {serviceTransitions}회 + 수집된 Span{trace.events.length}개
{failureEvent ? ( @@ -52,17 +45,13 @@ export function TraceOutcomeSummary({
{outcome === 'recovered' ? '복구된 실패' : '주요 실패 원인'} - {failureLabel} · {failureEvent.errorType ?? EVENT_STATUS_LABEL[failureEvent.status]} + {failureEvent.serviceName ? `${failureEvent.serviceName} / ` : ''}{failureLabel} + {failureEvent.errorType ?? EVENT_STATUS_LABEL[failureEvent.status]}

{failureEvent.errorMessage ?? `${failureEvent.eventType} 실행 중 문제가 발생했습니다.`}

{propagationPath.length > 1 ? (
오류 전파 - {propagationPath.map((event, index) => ( - - {index > 0 ? - ))} + {propagationLabel}
) : null}
@@ -71,18 +60,26 @@ export function TraceOutcomeSummary({
- ) : ( -
+ ) : !collectionTimedOut ? ( +
- 실행 결과 - {copy.label} -

{copy.detail}

+

{resultDetail}

+
+
+ ) : null} + + {collectionTimedOut ? ( +
+
- )} + ) : null}
) } diff --git a/frontend/src/features/workbench/views/TraceView.css b/frontend/src/features/workbench/views/TraceView.css index 7a21cd3..2908315 100644 --- a/frontend/src/features/workbench/views/TraceView.css +++ b/frontend/src/features/workbench/views/TraceView.css @@ -80,10 +80,18 @@ .trace-outcome__metrics { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.trace-outcome__metrics--primary { border-bottom: 1px solid var(--color-line); } +.trace-outcome__metrics--scope { + border-bottom: 1px solid var(--color-line); + background: #f8fafc; +} + .trace-outcome__metrics > span { display: grid; gap: 3px; @@ -117,10 +125,6 @@ color: var(--color-error); } -.trace-outcome--collection_timeout .trace-outcome__metrics > span:first-child strong { - color: var(--color-warning); -} - .trace-cause { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -141,11 +145,6 @@ background: #f2faf6; } -.trace-cause--collection-timeout { - border-left-color: var(--color-warning); - background: #fffaf0; -} - .trace-cause__icon { display: grid; place-items: center; @@ -162,10 +161,6 @@ color: var(--color-success); } -.trace-cause--collection-timeout .trace-cause__icon { - color: var(--color-warning); -} - .trace-cause__body { display: grid; min-width: 0; @@ -184,6 +179,12 @@ font: 700 0.76rem/1.4 var(--font-mono); } +.trace-cause__body > small { + overflow-wrap: anywhere; + color: #7b5a56; + font: 600 0.6rem/1.4 var(--font-mono); +} + .trace-cause__body > p { margin: 0; color: #6f4b48; @@ -205,27 +206,75 @@ cursor: pointer; } +.trace-cause--success { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + padding-block: 10px; +} + +.trace-cause--success .trace-cause__body > p { + color: #356b55; +} + +.trace-collection-warning { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 11px; + border-left: 3px solid var(--color-warning); + background: #fffaf0; + color: var(--color-warning); + padding: 11px 14px 11px 13px; +} + +.trace-cause + .trace-collection-warning { + border-top: 1px solid #f1dfbd; +} + +.trace-collection-warning svg { + margin-top: 2px; +} + +.trace-collection-warning div { + min-width: 0; +} + +.trace-collection-warning strong { + display: block; + color: #8a5100; + font-size: 0.7rem; +} + +.trace-collection-warning p { + margin: 3px 0 0; + overflow-wrap: anywhere; + color: #765f3a; + font-size: 0.66rem; + line-height: 1.5; +} + .trace-propagation { display: flex; align-items: center; - flex-wrap: wrap; gap: 4px; + min-width: 0; margin-top: 6px; color: #5b6572; font: 600 0.6rem/1.4 var(--font-mono); } .trace-propagation b { + flex: 0 0 auto; margin-right: 4px; color: var(--color-ink); font-family: var(--font-sans); } .trace-propagation span { - display: inline-flex; align-items: center; min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .waterfall-row.is-primary-failure { @@ -497,22 +546,11 @@ } .trace-outcome__metrics > span:nth-child(3) { + grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; } - .trace-outcome__metrics > span:nth-child(4) { - border-top: 1px solid var(--color-line); - } - - .trace-outcome__metrics > span:nth-child(n + 3) { - border-top: 1px solid var(--color-line); - } - - .trace-outcome__metrics > span:nth-child(odd) { - border-left: 0; - } - .trace-cause { grid-template-columns: auto minmax(0, 1fr); } diff --git a/frontend/src/features/workbench/views/TraceView.tsx b/frontend/src/features/workbench/views/TraceView.tsx index 07b8b78..1f00c6a 100644 --- a/frontend/src/features/workbench/views/TraceView.tsx +++ b/frontend/src/features/workbench/views/TraceView.tsx @@ -17,7 +17,7 @@ export function TraceView({ model }: TraceViewProps) { const { traceDetail, recentTraces, traceHistoryFilter, setTraceHistoryFilter, filteredRecentTraces, selectTrace, selectedDomain, selectedApi, - selectedApiMethodLabel, traceDisplayTone, traceDisplayStatus, + selectedApiMethodLabel, runtimeSupported, externalTraceConfigured, analysisTarget, setActiveView, traceOutcome, primaryFailureEvent, primaryFailureLabel, failurePropagationPath, primaryFailureNodeId, setSelectedNodeId, @@ -57,9 +57,6 @@ export function TraceView({ model }: TraceViewProps) { : '프로젝트 구조에서 Agent 실행 설정을 먼저 생성하세요.'}

- - {traceDisplayStatus} - {!traceDetail ? ( @@ -76,7 +73,7 @@ export function TraceView({ model }: TraceViewProps) { <> { render( { const { container } = render( , ) - expect(within(container).getAllByText('Span 수집 시간 초과')).not.toHaveLength(0) + expect(within(container).getByText('HTTP 요청 성공')).toBeInTheDocument() + expect(within(container).getByText('Span 수집 시간 초과')).toBeInTheDocument() expect(within(container).queryByText('요청 실패')).not.toBeInTheDocument() }) diff --git a/frontend/src/features/workbench/views/views.test.tsx b/frontend/src/features/workbench/views/views.test.tsx index 68954b2..7bdd90c 100644 --- a/frontend/src/features/workbench/views/views.test.tsx +++ b/frontend/src/features/workbench/views/views.test.tsx @@ -17,9 +17,7 @@ vi.mock('../hooks/useWorkbenchController', () => ({ hasDetectedApis: true, requestReady: true, traceId: mocks.activeView === 'runtime' ? 'trace-1' : null, - traceResultStatus: 'IDLE', - traceDisplayStatus: '대기', - traceEventCount: 0, + traceCollectionPresentation: { label: 'Trace 대기', tone: 'neutral' }, }, projectView: {}, requestView: {},