diff --git a/frontend/e2e/distributed-trace.spec.ts b/frontend/e2e/distributed-trace.spec.ts
index f1327ad..1157f73 100644
--- a/frontend/e2e/distributed-trace.spec.ts
+++ b/frontend/e2e/distributed-trace.spec.ts
@@ -24,6 +24,10 @@ test.describe.serial('분산 Trace 사용자 흐름', () => {
await expectMetric(outcome, '진입 서비스', 'order-service')
await expect(outcome.getByText(/2개 · 경계 1회/)).toBeVisible()
+ const history = page.getByRole('region', { name: '최근 Trace 탐색' })
+ await expect(history).toBeVisible()
+ await expect(history.locator('.trace-item.is-selected')).toHaveAccessibleName(/\/lab\/orders\/2001 · 성공/)
+
const waterfall = page.getByRole('region', { name: 'Span 타임라인' })
await expect(waterfall.getByText('서비스 경계', { exact: true })).toBeVisible()
await expect(waterfall.getByText('order-service → product-service', { exact: true })).toBeVisible()
@@ -32,6 +36,16 @@ test.describe.serial('분산 Trace 사용자 흐름', () => {
await expect(page.locator('.service-area').filter({ hasText: 'order-service' })).toBeVisible()
await expect(page.locator('.service-area').filter({ hasText: 'product-service' })).toBeVisible()
await expect(page.locator('.react-flow__edge-text').filter({ hasText: 'order-service → product-service' })).toBeVisible()
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await expect.poll(() => page.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ )).toBe(0)
+ const graphTop = await page.locator('.graph-panel').evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
+ const inspectorTop = await page.locator('.workspace--runtime .right-panel').evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
+ const historyTop = await page.getByRole('region', { name: '최근 Trace 탐색' }).evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
+ expect(graphTop).toBeLessThan(inspectorTop)
+ expect(inspectorTop).toBeLessThan(historyTop)
})
test('Product PostgreSQL timeout의 원인과 Order까지의 오류 전파를 표시한다', async ({ page }) => {
@@ -53,11 +67,6 @@ test.describe.serial('분산 Trace 사용자 흐름', () => {
await expect(inspector).toContainText(/PostgreSQL|POSTGRESQL/)
await expect(inspector.locator('.trace-inspector-error')).toBeVisible()
- await page.setViewportSize({ width: 390, height: 844 })
- await expect.poll(() => page.evaluate(() =>
- document.documentElement.scrollWidth - document.documentElement.clientWidth,
- )).toBe(0)
- await expect(page.getByRole('region', { name: 'Span 타임라인' })).toBeVisible()
})
})
diff --git a/frontend/src/features/workbench/views/TraceHistoryPanel.tsx b/frontend/src/features/workbench/views/TraceHistoryPanel.tsx
index 422bb72..d815f14 100644
--- a/frontend/src/features/workbench/views/TraceHistoryPanel.tsx
+++ b/frontend/src/features/workbench/views/TraceHistoryPanel.tsx
@@ -1,3 +1,4 @@
+import { ArrowRight } from 'lucide-react'
import type { TraceSummary } from '../../../types/trace'
import { EVENT_STATUS_LABEL } from '../../../ui/copy'
import type { TraceHistoryFilter } from '../traceModel'
@@ -9,6 +10,7 @@ interface TraceHistoryPanelProps {
selectedTraceId: string | null
onFilterChange: (filter: TraceHistoryFilter) => void
onSelectTrace: (traceId: string) => void
+ onNavigateToRequest: () => void
}
const FILTERS: { value: TraceHistoryFilter; label: string }[] = [
@@ -25,9 +27,10 @@ export function TraceHistoryPanel({
selectedTraceId,
onFilterChange,
onSelectTrace,
+ onNavigateToRequest,
}: TraceHistoryPanelProps) {
return (
-
+
실행 기록
@@ -52,17 +55,28 @@ export function TraceHistoryPanel({
{traces.length === 0 ? (
-
{totalCount === 0 ? '아직 수집된 Trace가 없습니다.' : '이 조건에 맞는 Trace가 없습니다.'}
+
+
{totalCount === 0 ? '아직 수집된 Trace가 없습니다.' : '이 조건에 맞는 Trace가 없습니다.'}
+ {totalCount === 0 ? (
+
+ ) : null}
+
) : (
traces.map((trace) => {
const collectionTimedOut = trace.traceCollectionStatus === 'TIMED_OUT'
const tone = collectionTimedOut ? 'collection-timeout' : trace.resultStatus.toLowerCase()
+ const statusLabel = collectionTimedOut ? '수집 시간 초과' : EVENT_STATUS_LABEL[trace.resultStatus]
return (
-
+
)
}
diff --git a/frontend/src/features/workbench/views/TraceView.css b/frontend/src/features/workbench/views/TraceView.css
index 1bec0a0..7a21cd3 100644
--- a/frontend/src/features/workbench/views/TraceView.css
+++ b/frontend/src/features/workbench/views/TraceView.css
@@ -1,6 +1,30 @@
.trace-history-panel {
min-width: 0;
}
+
+.trace-history-empty {
+ display: grid;
+ justify-items: start;
+ gap: 10px;
+}
+
+.trace-history-empty p {
+ margin: 0;
+}
+
+.trace-history-empty button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid rgba(37, 99, 235, 0.28);
+ border-radius: 5px;
+ background: var(--color-surface);
+ color: var(--color-action);
+ padding: 7px 9px;
+ font-size: 0.68rem;
+ font-weight: 750;
+ cursor: pointer;
+}
.trace-history-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -452,35 +476,6 @@
}
}
-@media (max-width: 1220px) {
- .workspace--runtime .right-panel {
- grid-column: 2;
- grid-template-columns: 1fr;
- }
-}
-
-@media (min-width: 1221px) and (max-width: 1320px) {
- .workspace--runtime .right-panel {
- grid-column: 2;
- grid-template-columns: 1fr;
- }
-
- .workspace--runtime .trace-inspector {
- border-right: 0;
- }
-}
-
-@media (max-width: 980px) {
- .workspace--runtime .right-panel {
- grid-column: 1;
- grid-row: 3;
- }
-
- .workspace--runtime .left-panel {
- grid-row: 4;
- }
-}
-
@media (max-width: 720px) {
.workspace--runtime,
.workspace--runtime > *,
@@ -554,8 +549,7 @@
}
.workspace--project .recent-card,
-.workspace--api .recent-card,
-.workspace--runtime .control-card {
+.workspace--api .recent-card {
display: none;
}
@@ -1287,35 +1281,23 @@
@media (min-width: 1221px) and (max-width: 1320px) {
.workspace--runtime {
- grid-template-columns: 280px minmax(0, 1fr);
+ grid-template-columns: 240px minmax(0, 1fr) 320px;
+ }
+}
+
+@media (max-width: 1220px) {
+.workspace--runtime {
+ grid-template-columns: 330px minmax(0, 1fr);
}
.workspace--runtime .right-panel {
position: static;
grid-column: 1 / -1;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 0;
max-height: none;
border-top: 1px solid var(--color-line);
border-left: 0;
overflow: visible;
}
-
-.workspace--runtime .inspector-workbench,
-.workspace--runtime .timeline-card {
- border-right: 1px solid var(--color-line);
- }
-}
-
-@media (max-width: 1220px) {
-.workspace--runtime {
- grid-template-columns: 330px minmax(0, 1fr);
- }
-
-.timeline-card {
- border-right: 1px solid var(--color-line);
- }
}
@media (max-width: 980px) {
@@ -1324,11 +1306,12 @@
}
.workspace--runtime .left-panel {
- grid-row: 3;
+ grid-row: 4;
}
.workspace--runtime .right-panel {
- grid-row: 4;
+ grid-column: 1;
+ grid-row: 3;
}
}
diff --git a/frontend/src/features/workbench/views/TraceView.tsx b/frontend/src/features/workbench/views/TraceView.tsx
index 7955f6e..07b8b78 100644
--- a/frontend/src/features/workbench/views/TraceView.tsx
+++ b/frontend/src/features/workbench/views/TraceView.tsx
@@ -29,25 +29,15 @@ export function TraceView({ model }: TraceViewProps) {
return (
diff --git a/frontend/src/features/workbench/views/traceWorkspace.test.tsx b/frontend/src/features/workbench/views/traceWorkspace.test.tsx
index a5f48c9..80f1605 100644
--- a/frontend/src/features/workbench/views/traceWorkspace.test.tsx
+++ b/frontend/src/features/workbench/views/traceWorkspace.test.tsx
@@ -64,7 +64,7 @@ describe('Trace workspace presentation', () => {
httpStatus: 200, durationMs: 12, startedAt: new Date(0).toISOString(), traceCollectionStatus: 'DISABLED',
}]
render(
- ,
+ ,
)
fireEvent.click(screen.getByRole('button', { name: '확인 필요' }))
expect(onFilterChange).toHaveBeenCalledWith('attention')
@@ -76,12 +76,28 @@ describe('Trace workspace presentation', () => {
httpStatus: 200, durationMs: 12, startedAt: new Date(0).toISOString(), traceCollectionStatus: 'TIMED_OUT',
}]
const { container } = render(
- ,
+ ,
)
const timeoutPill = within(container).getByText('수집 시간 초과')
expect(timeoutPill).toHaveClass('pill--warning')
})
+ it('offers API navigation only when there is no trace history at all', () => {
+ const onNavigateToRequest = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'API 요청으로 이동' }))
+ expect(onNavigateToRequest).toHaveBeenCalledOnce()
+
+ rerender(
+ ,
+ )
+ expect(screen.getByText('이 조건에 맞는 Trace가 없습니다.')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'API 요청으로 이동' })).not.toBeInTheDocument()
+ })
+
it('shows selected span facts and keeps raw details collapsed', () => {
const selectedNode: GraphNodeState = {
id: 'postgres', component: 'POSTGRESQL', label: 'SELECT product', status: 'TIMEOUT',