Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion frontend/e2e/distributed-trace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ test.describe.serial('분산 Trace 사용자 흐름', () => {

await expect(page.getByLabel('대상 기본 URL')).toHaveValue('http://order-service:8092')
await expect(page.getByLabel('Path variable 값')).toHaveValue('2001')
await expect(page.getByRole('region', { name: 'API 응답' })).toBeVisible()
await expect(page.locator('.request-flow-disclosure')).not.toHaveAttribute('open', '')

await page.setViewportSize({ width: 390, height: 844 })
await expect(page.getByRole('button', { name: /API 변경/ })).toBeVisible()
await expect.poll(() => page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth,
)).toBe(0)
const composerTop = await page.getByRole('region', { name: 'API 요청 작성' }).evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
const responseTop = await page.getByRole('region', { name: 'API 응답' }).evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
const explorerTop = await page.locator('.request-endpoint-rail').evaluate((element) => element.getBoundingClientRect().top + window.scrollY)
expect(composerTop).toBeLessThan(responseTop)
expect(responseTop).toBeLessThan(explorerTop)
await page.setViewportSize({ width: 1440, height: 900 })

await page.getByRole('button', { name: '요청 보내고 Trace 보기' }).click()

const outcome = page.getByRole('region', { name: 'Trace 실행 결과' })
Expand Down Expand Up @@ -86,7 +101,7 @@ async function openEndpoint(page: Page, endpoint: string) {
})
await expect(endpointButton).toHaveCount(1)
await endpointButton.click()
await expect(page.getByText(endpoint, { exact: true }).first()).toBeVisible()
await expect(endpointButton).toHaveClass(/is-selected/u)
}

async function expectMetric(outcome: ReturnType<Page['getByRole']>, label: string, value: string) {
Expand Down
21 changes: 12 additions & 9 deletions frontend/src/features/workbench/hooks/useWorkbenchController.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { buildGraph, getNodeDetail } from '../../../lib/graph'
import { buildWaterfall, getPrimaryFailureEvent } from '../../../lib/waterfall'
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 { buildRequestResponsePresentation, createRequestEntry, filterApis } from '../requestModel'
import { buildFailurePropagationPath, buildTraceOutcomePresentation, filterTraceHistory, getDefaultInspectionEvent, getInspectorEvent, getTraceCollectionPresentation } from '../traceModel'
import {
buildCommonProjectLayers,
Expand Down Expand Up @@ -143,12 +143,15 @@ export function useWorkbenchController() {
)
const selectedApiMethodLabel = getApiMethodLabel(selectedApi)
const selectedApiMethodClassName = getApiMethodBadgeClassName(selectedApi)
const formattedResponseBody = useMemo(() => request.lastResponseBody
? JSON.stringify(request.lastResponseBody, null, 2)
: null, [request.lastResponseBody])
const formattedExternalResponseBody = useMemo(() => request.externalResponse
? formatResponseBody(request.externalResponse.responseBody)
: null, [request.externalResponse])
const requestResponsePresentation = buildRequestResponsePresentation({
externalRunnable,
requestState: request.requestState,
requestMessage: request.requestMessage,
externalResponse: request.externalResponse,
traceDetail: runtime.traceDetail,
traceCollectionStatus: runtime.traceCollectionStatus,
sampleResponseBody: request.lastResponseBody,
})
const graphFitKey = `${runtime.traceDetail?.traceId ?? 'empty'}-${runtime.traceDetail?.events.length ?? 0}`
const selectedTraceId = runtime.traceDetail?.traceId
const setSelectedNodeId = runtime.setSelectedNodeId
Expand Down Expand Up @@ -212,7 +215,7 @@ export function useWorkbenchController() {
hasDetectedApis: hasUsableApis, hasConcreteMethod, runtimeSupported, externalRunnable, analyzeOnly,
externalTraceConfigured, externalTraceVerified, hasIntegrationBoundary,
estimatedFlow, runtimeModeLabel, selectedApiMethodLabel,
selectedApiMethodClassName, formattedResponseBody, formattedExternalResponseBody,
selectedApiMethodClassName, requestResponsePresentation,
traceDetail: runtime.traceDetail,
traceCollectionStatus: runtime.traceCollectionStatus,
},
Expand Down
113 changes: 112 additions & 1 deletion frontend/src/features/workbench/requestModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,38 @@ import type {
ExternalRequestEntry,
ExternalRequestResponse,
ProductPayload,
TraceCollectionStatus,
TraceDetail,
} from '../../types/trace'
import { EVENT_STATUS_LABEL } from '../../ui/copy'
import type { ApiDefinition } from './types'
import type { ApiDefinition, AsyncState } from './types'

export type RequestResponseTone = 'neutral' | 'info' | 'success' | 'warning' | 'error'

export interface RequestResponsePresentation {
phase: 'idle' | 'loading' | 'success' | 'warning' | 'failure'
resultLabel: string
statusLabel: string
tone: RequestResponseTone
durationMs: number | null
contentType: string | null
traceId: string | null
body: string | null
bodyTruncated: boolean
errorMessage: string | null
collectionTimedOut: boolean
emptyMessage: string
}

type RequestResponsePresentationOptions = {
externalRunnable: boolean
requestState: AsyncState
requestMessage: string
externalResponse: ExternalRequestResponse | null
traceDetail: TraceDetail | null
traceCollectionStatus: TraceCollectionStatus
sampleResponseBody: unknown
}

export function buildRequestMessage(resultStatus: EventStatus, payload: ProductPayload) {
if (payload.errorMessage) return `${EVENT_STATUS_LABEL[resultStatus]}: ${payload.errorMessage}`
Expand Down Expand Up @@ -84,6 +113,78 @@ export function formatResponseBody(responseBody: string) {
}
}

export function buildRequestResponsePresentation({
externalRunnable,
requestState,
requestMessage,
externalResponse,
traceDetail,
traceCollectionStatus,
sampleResponseBody,
}: RequestResponsePresentationOptions): RequestResponsePresentation {
if (externalRunnable && externalResponse) {
const status = externalResponse.resultStatus
return {
phase: status === 'SUCCESS' ? 'success' : 'failure',
resultLabel: status === 'SUCCESS' ? '요청 성공' : '요청 실패',
statusLabel: externalResponse.httpStatus > 0 ? `HTTP ${externalResponse.httpStatus}` : '전송 실패',
tone: status === 'SUCCESS' ? 'success' : 'error',
durationMs: externalResponse.durationMs,
contentType: externalResponse.contentType || null,
traceId: externalResponse.traceId,
body: formatResponseBody(externalResponse.responseBody) || null,
bodyTruncated: externalResponse.responseBodyTruncated,
errorMessage: externalResponse.errorMessage,
collectionTimedOut: traceCollectionStatus === 'TIMED_OUT'
|| externalResponse.traceCollectionStatus === 'TIMED_OUT',
emptyMessage: '응답 본문이 없습니다.',
}
}

if (!externalRunnable && traceDetail) {
const failure = traceDetail.resultStatus === 'ERROR' || traceDetail.resultStatus === 'TIMEOUT'
const warning = traceDetail.resultStatus === 'WARNING'
return {
phase: failure ? 'failure' : warning ? 'warning' : 'success',
resultLabel: failure ? '요청 실패' : warning ? '주의와 함께 완료' : '요청 성공',
statusLabel: traceDetail.httpStatus > 0 ? `HTTP ${traceDetail.httpStatus}` : '실행 완료',
tone: failure ? 'error' : warning ? 'warning' : 'success',
durationMs: traceDetail.durationMs,
contentType: 'application/json',
traceId: traceDetail.traceId,
body: formatSampleResponseBody(sampleResponseBody),
bodyTruncated: false,
errorMessage: failure ? requestMessage : null,
collectionTimedOut: traceDetail.traceCollectionStatus === 'TIMED_OUT',
emptyMessage: '응답 본문이 없습니다.',
}
}

if (requestState === 'loading') {
return {
phase: 'loading', resultLabel: '요청 중', statusLabel: '응답 대기', tone: 'info',
durationMs: null, contentType: null, traceId: null, body: null, bodyTruncated: false,
errorMessage: null, collectionTimedOut: false, emptyMessage: '대상 API의 응답을 기다리고 있습니다.',
}
}

if (requestState === 'error') {
return {
phase: 'failure', resultLabel: '요청 실패', statusLabel: '전송 실패', tone: 'error',
durationMs: null, contentType: null, traceId: null, body: null, bodyTruncated: false,
errorMessage: requestMessage, collectionTimedOut: false,
emptyMessage: '대상 주소와 서비스 실행 상태를 확인하세요.',
}
}

return {
phase: 'idle', resultLabel: '응답 대기', statusLabel: '대기', tone: 'neutral',
durationMs: null, contentType: null, traceId: null, body: null, bodyTruncated: false,
errorMessage: null, collectionTimedOut: false,
emptyMessage: '요청을 실행하면 HTTP 응답이 여기에 표시됩니다.',
}
}

export function parseResponseBody(responseBody: string): unknown {
if (!responseBody) return null
try {
Expand All @@ -92,3 +193,13 @@ export function parseResponseBody(responseBody: string): unknown {
return responseBody
}
}

function formatSampleResponseBody(responseBody: unknown): string | null {
if (responseBody === null || responseBody === undefined) return null
if (typeof responseBody === 'string') return responseBody
try {
return JSON.stringify(responseBody, null, 2)
} catch {
return String(responseBody)
}
}
113 changes: 36 additions & 77 deletions frontend/src/features/workbench/views/RequestComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { ArrowLeft, ExternalLink, Plus, Send, Trash2 } from 'lucide-react'
import { Plus, Send, Trash2 } from 'lucide-react'
import type {
ExternalRequestEntry,
ExternalRequestResponse,
TraceCollectionStatus,
TraceDetail,
} from '../../../types/trace'
import { TRACE_COLLECTION_STATUS_LABEL } from '../../../ui/copy'
import { SCENARIOS } from '../fixtures'
import { countEnabledEntries } from '../requestModel'
import type { ApiDefinition, AsyncState, RequestOptionTab, ScenarioValue } from '../types'
Expand Down Expand Up @@ -35,12 +32,8 @@ type RequestComposerProps = {
bodyAllowed: boolean
requestState: AsyncState
requestMessage: string
externalResponse: ExternalRequestResponse | null
traceDetail: TraceDetail | null
traceCollectionStatus: TraceCollectionStatus
formattedExternalResponseBody: string | null
formattedResponseBody: string | null
onBackProject: () => void
responseAvailable: boolean
onTargetBaseUrlChange: (value: string) => void
onProductIdChange: (value: string) => void
onScenarioChange: (value: ScenarioValue) => void
Expand All @@ -54,7 +47,6 @@ type RequestComposerProps = {
onRemoveQueryParam: (id: string) => void
onRemoveRequestHeader: (id: string) => void
onRunRequest: () => void
onOpenTrace: () => void
}

export function RequestComposer(props: RequestComposerProps) {
Expand Down Expand Up @@ -83,24 +75,20 @@ export function RequestComposer(props: RequestComposerProps) {
bodyAllowed,
requestState,
requestMessage,
externalResponse,
traceDetail,
traceCollectionStatus,
formattedExternalResponseBody,
formattedResponseBody,
responseAvailable,
} = props
const traceId = externalResponse?.traceId ?? traceDetail?.traceId ?? null
const runLabel = getRunLabel(requestState, runtimeSupported, externalRunnable, externalTraceConfigured, externalTraceVerified)
const responseStatus = externalRunnable ? externalResponse?.resultStatus : traceDetail?.resultStatus
const responseHttpStatus = externalRunnable ? externalResponse?.httpStatus : traceDetail?.httpStatus
const targetMissing = externalRunnable && !targetBaseUrl.trim()
const requestBodyValidationError = requestBodyError ?? validateRequestBody(bodyAllowed, requestBody)
const showLiveStatus = requestState === 'loading'
|| requestState === 'error' && !responseAvailable
|| traceCollectionStatus === 'PENDING'
|| traceCollectionStatus === 'COLLECTING'

return (
<section className="request-composer" aria-label="API 요청 작성">
<header className="request-composer__header">
<button type="button" className="request-back-button" onClick={props.onBackProject}>
<ArrowLeft size={15} aria-hidden="true" />
프로젝트 구조
</button>
<div className="request-breadcrumb">
<span>{domainName}</span>
<strong>{selectedApi.controller}.{selectedApi.handler}</strong>
Expand Down Expand Up @@ -136,7 +124,11 @@ export function RequestComposer(props: RequestComposerProps) {
className="run-button request-command-run"
type="button"
onClick={props.onRunRequest}
disabled={requestState === 'loading' || !hasDetectedApis || analyzeOnly}
disabled={requestState === 'loading'
|| !hasDetectedApis
|| analyzeOnly
|| targetMissing
|| Boolean(requestBodyValidationError)}
>
<Send size={17} aria-hidden="true" />
{runLabel}
Expand All @@ -146,6 +138,9 @@ export function RequestComposer(props: RequestComposerProps) {
{!selectedApi.methodSpecified ? (
<p className="request-method-warning">Controller mapping에 HTTP method가 명시되지 않아 요청을 실행할 수 없습니다.</p>
) : null}
{targetMissing ? (
<p className="request-method-warning">요청을 실행하려면 대상 기본 URL을 입력하세요.</p>
) : null}

<div className="request-form request-form--workspace">
{externalRunnable ? (
Expand Down Expand Up @@ -200,7 +195,7 @@ export function RequestComposer(props: RequestComposerProps) {
rows={8}
spellCheck={false}
/>
{requestBodyError ? <small className="request-message--error">{requestBodyError}</small> : null}
{requestBodyValidationError ? <small className="request-message--error">{requestBodyValidationError}</small> : null}
</label>
) : null}
</div>
Expand Down Expand Up @@ -230,56 +225,12 @@ export function RequestComposer(props: RequestComposerProps) {
</label>
) : null}

<div className={`request-live-status request-live-status--${requestState}`} aria-live="polite">
<span>{getExecutionStatus(requestState, traceCollectionStatus, externalResponse, traceDetail)}</span>
<p>{requestMessage}</p>
</div>

<section className="request-result-panel" aria-label="API 응답">
<div className="request-result-head">
<div>
<span className="section-label">응답</span>
<strong>{externalRunnable ? '외부 HTTP 결과' : '실행 결과'}</strong>
</div>
<div>
<span className={`pill pill--inline pill--${(responseStatus ?? 'idle').toLowerCase()}`}>
{responseHttpStatus ? `HTTP ${responseHttpStatus}` : '대기'}
</span>
{traceId ? (
<button type="button" className="trace-open-button" onClick={props.onOpenTrace}>
Trace에서 보기 <ExternalLink size={14} aria-hidden="true" />
</button>
) : null}
{externalResponse?.responseBodyTruncated ? (
<span className="response-truncated-badge" role="status">1MiB 일부</span>
) : null}
</div>
{showLiveStatus ? (
<div className={`request-live-status request-live-status--${requestState}`} aria-live="polite">
<span>{getExecutionStatus(requestState, traceCollectionStatus)}</span>
<p>{requestMessage}</p>
</div>
{externalRunnable ? (
externalResponse ? (
<>
<div className="request-result-meta">
<span><strong>{externalResponse.durationMs}ms</strong>소요 시간</span>
<span><strong>{externalResponse.contentType || '-'}</strong>Content-Type</span>
<span><strong>{externalResponse.traceId?.slice(0, 8) || '-'}</strong>Trace ID</span>
</div>
{externalResponse.errorMessage ? <p className="external-error">{externalResponse.errorMessage}</p> : null}
{formattedExternalResponseBody
? <pre className="response-body response-body--external">{formattedExternalResponseBody}</pre>
: <p className="empty-copy">응답 본문이 없습니다.</p>}
</>
) : <p className="empty-copy">대상 URL을 입력하고 요청을 보내면 응답이 표시됩니다.</p>
) : formattedResponseBody ? (
<>
<div className="request-result-meta">
<span><strong>{traceDetail?.durationMs ?? 0}ms</strong>소요 시간</span>
<span><strong>{traceDetail?.events.length ?? 0}</strong>실행 이벤트</span>
<span><strong>{traceDetail?.traceId.slice(0, 8) ?? '-'}</strong>Trace ID</span>
</div>
<pre className="response-body response-body--external">{formattedResponseBody}</pre>
</>
) : <p className="empty-copy">요청을 실행하면 JSON 응답이 표시됩니다.</p>}
</section>
) : null}
</div>
</section>
)
Expand Down Expand Up @@ -366,12 +317,20 @@ function getRunLabel(
function getExecutionStatus(
requestState: AsyncState,
traceCollectionStatus: TraceCollectionStatus,
externalResponse: ExternalRequestResponse | null,
traceDetail: TraceDetail | null,
) {
if (requestState === 'loading') return traceCollectionStatus === 'PENDING' ? 'Span 대기' : '요청 중'
if (requestState === 'error') return '요청 실패'
if (traceCollectionStatus !== 'DISABLED') return TRACE_COLLECTION_STATUS_LABEL[traceCollectionStatus]
if (externalResponse || traceDetail) return '완료'
return '대기'
if (traceCollectionStatus === 'PENDING') return 'Span 대기'
if (traceCollectionStatus === 'COLLECTING') return '수집 중'
return '응답 수신'
}

function validateRequestBody(bodyAllowed: boolean, requestBody: string) {
if (!bodyAllowed || !requestBody.trim()) return null
try {
JSON.parse(requestBody)
return null
} catch {
return '요청 본문은 올바른 JSON 형식이어야 합니다.'
}
}
Loading
Loading