feat: [flow-tester] API 로그 캡처 및 UI 개선

- ApiLogCapturer 추가: 플로우 실행 중 API 로그 캡처
- resolveBaseUrl() 추가: .env 환경변수 기반 baseUrl 지원
- 실행 상세 페이지: 스텝별 접기/펼치기 기능 (성공=접힘, 실패=펼침)
- JSON 가이드 및 예제 플로우 최신화
- AI 프롬프트 템플릿 업데이트
- bindExpectVariables() 추가: expect jsonPath 값에 변수 바인딩 적용
- areNumericEqual() 추가: 숫자 타입 유연 비교 ("2" == 2)
This commit is contained in:
2025-12-04 15:30:04 +09:00
parent 20cfa01579
commit fe10cae06c
9 changed files with 709 additions and 143 deletions

View File

@@ -78,11 +78,13 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap w-12">#</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">이름</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">카테고리</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">스텝</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">최근 실행</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">상태</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">생성일</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">액션</th>
</tr>
</thead>
@@ -94,6 +96,9 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
@endphp
{{-- 메인 row --}}
<tr class="hover:bg-gray-50 cursor-pointer flow-row" data-flow-id="{{ $flow->id }}" onclick="toggleFlowDetail({{ $flow->id }}, event)">
<td class="px-3 py-4 text-center text-sm text-gray-500">
{{ $flows->firstItem() + $loop->index }}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400 transition-transform flow-chevron" id="chevron-{{ $flow->id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -107,24 +112,24 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
</div>
</div>
</td>
<td class="px-6 py-4">
<td class="px-6 py-4 whitespace-nowrap">
@if($flow->category)
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded">{{ $flow->category }}</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{{ $flow->step_count }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
@if($latestRun)
{{ $latestRun->created_at->diffForHumans() }}
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4">
<td class="px-6 py-4 whitespace-nowrap">
@if($latestRun)
<span class="px-2 py-1 text-xs font-medium rounded {{ $latestRun->status_color }}">
{{ $latestRun->status_label }}
@@ -133,8 +138,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-500 rounded">대기</span>
@endif
</td>
<td class="px-6 py-4 text-right" onclick="event.stopPropagation()">
<div class="flex justify-end gap-2">
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{{ $flow->created_at->format('y.m.d') }}
</td>
<td class="px-4 py-4 text-right" onclick="event.stopPropagation()">
<div class="flex justify-end gap-1">
<!-- 실행 버튼 -->
<button onclick="runFlow({{ $flow->id }})"
class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition"
@@ -193,7 +201,7 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
}
@endphp
<tr id="flow-detail-{{ $flow->id }}" class="hidden">
<td colspan="6" class="px-6 py-4 bg-gray-50">
<td colspan="8" class="px-6 py-4 bg-gray-50">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<h4 class="text-sm font-semibold text-gray-700">플로우 스텝 ({{ count($steps) }})</h4>
@@ -555,6 +563,8 @@ function showResultModal(data) {
</div>
` : ''}
${getApiLogSummary(result.apiLogs)}
<div class="flex gap-3">
<button onclick="this.closest('.fixed').remove(); location.reload();"
class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
@@ -571,6 +581,56 @@ class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-center rou
document.body.appendChild(modal);
}
// API 로그 요약 생성
function getApiLogSummary(apiLogs) {
if (!apiLogs || apiLogs.length === 0) {
return '';
}
// Request/Response 분리 및 에러 카운트
const requests = apiLogs.filter(l => l.type === 'request');
const responses = apiLogs.filter(l => l.type === 'response');
const errors = responses.filter(l => (l.status || 0) >= 400 || l.success === false);
const hasErrors = errors.length > 0;
// 최대 3개만 표시
const displayLogs = apiLogs.slice(0, 6);
const logItems = displayLogs.map(log => {
const isRequest = log.type === 'request';
const isError = !isRequest && ((log.status || 0) >= 400 || log.success === false);
if (isRequest) {
return `<div class="flex items-center gap-2 py-1 text-sm">
<span class="px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 rounded">REQ</span>
<code class="text-gray-700 text-xs">${log.method || ''} ${log.uri || ''}</code>
</div>`;
} else {
return `<div class="flex items-center gap-2 py-1 text-sm ${isError ? 'text-red-600' : ''}">
<span class="px-1.5 py-0.5 text-xs font-medium ${isError ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'} rounded">RES</span>
<span class="px-1.5 py-0.5 text-xs font-medium ${isError ? 'bg-red-600' : 'bg-green-600'} text-white rounded">${log.status || '-'}</span>
<code class="text-gray-500 text-xs truncate">${log.uri || ''}</code>
${isError && log.message ? `<span class="text-red-500 text-xs">${log.message}</span>` : ''}
</div>`;
}
}).join('');
const moreCount = apiLogs.length > 6 ? apiLogs.length - 6 : 0;
return `
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
API 로그
<span class="text-xs font-normal text-gray-400">(${responses.length}개 응답)</span>
${hasErrors ? `<span class="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">${errors.length} 오류</span>` : ''}
</h4>
<div class="${hasErrors ? 'bg-red-50 border border-red-200' : 'bg-gray-50'} rounded-lg p-3 max-h-32 overflow-y-auto">
${logItems}
${moreCount > 0 ? `<div class="text-xs text-gray-400 mt-1">... ${moreCount} (상세 보기에서 확인)</div>` : ''}
</div>
</div>
`;
}
function confirmDelete(id, name) {
if (!confirm(`"${name}" 플로우를 삭제하시겠습니까?`)) return;

View File

@@ -409,53 +409,87 @@ class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-
]
},
'auth-flow': {
"name": "인증 플로우 테스트",
"description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.",
"version": "1.0",
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY_HERE",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"email": "test@example.com",
"password": "password123"
"user_id": "{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [
{
"id": "login",
"name": "로그인",
"method": "POST",
"endpoint": "/auth/login",
"body": { "email": "{{variables.email}}", "password": "{{variables.password}}" },
"expect": { "status": [200], "jsonPath": { "$.success": true, "$.data.token": "@isString" } },
"extract": { "accessToken": "$.data.token", "refreshToken": "$.data.refresh_token" }
"endpoint": "/login",
"body": {
"user_id": "{{variables.user_id}}",
"user_pwd": "{{variables.user_pwd}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.access_token": "@isString"
}
},
"extract": {
"accessToken": "$.access_token",
"refreshToken": "$.refresh_token"
}
},
{
"id": "get_profile",
"name": "프로필 조회",
"dependsOn": ["login"],
"method": "GET",
"endpoint": "/auth/me",
"headers": { "Authorization": "Bearer {{login.accessToken}}" },
"expect": { "status": [200], "jsonPath": { "$.data.email": "{{variables.email}}" } }
"endpoint": "/users/me",
"headers": {
"Authorization": "Bearer {{login.accessToken}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.success": true
}
}
},
{
"id": "refresh_token",
"name": "토큰 갱신",
"dependsOn": ["get_profile"],
"method": "POST",
"endpoint": "/auth/refresh",
"body": { "refresh_token": "{{login.refreshToken}}" },
"expect": { "status": [200], "jsonPath": { "$.data.token": "@isString" } },
"extract": { "newToken": "$.data.token" }
"endpoint": "/refresh",
"body": {
"refresh_token": "{{login.refreshToken}}"
},
"expect": {
"status": [200],
"jsonPath": {
"$.access_token": "@isString"
}
},
"extract": {
"newToken": "$.access_token"
}
},
{
"id": "logout",
"name": "로그아웃",
"dependsOn": ["refresh_token"],
"method": "POST",
"endpoint": "/auth/logout",
"headers": { "Authorization": "Bearer {{refresh_token.newToken}}" },
"expect": { "status": [200, 204] }
"endpoint": "/logout",
"headers": {
"Authorization": "Bearer {{refresh_token.newToken}}"
},
"expect": {
"status": [200, 204]
}
}
]
},

View File

@@ -53,19 +53,18 @@ class="text-gray-400 hover:text-gray-600 transition-colors">
<div class="prose prose-sm max-w-none">
<h4 class="text-lg font-semibold mb-3">1. 전체 구조</h4>
<pre class="bg-gray-100 p-4 rounded-lg text-xs overflow-x-auto"><code>{
"name": "플로우 이름", // 플로우 제목 (필수)
"description": "플로우 설명", // 상세 설명 (선택)
"version": "1.0",
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "@{{$env.API_KEY}}", // API 키 (선택)
"timeout": 30000,
"stopOnFailure": true,
"headers": {
"Accept": "application/json",
"Authorization": "Bearer @{{auth_token}}"
}
"stopOnFailure": true
},
"variables": {
"testPrefix": "TEST_",
"timestamp": "@{{$timestamp}}"
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}", // 환경변수 참조
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [...]
}</code></pre>
@@ -352,32 +351,29 @@ class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 te
[테스트하려는 기능/시나리오 설명]
## API 서버 정보
- Base URL: https://sam.kr/api/v1
- 인증: Bearer Token
- Base URL: https://api.sam.kr/api/v1
- API Key: 별도 제공 (config.apiKey에 설정)
- 인증: user_id, user_pwd로 로그인 Bearer Token 사용
## 테스트 플로우
1. [ 번째 단계]
2. [ 번째 단계] - 1번에서 생성된 ID 사용
2. [ 번째 단계] - 1번에서 추출한 사용
3. ...
## API 엔드포인트 참고
[Swagger 문서 URL 또는 API 명세]
## JSON 형식 (반드시 이 형식으로!)
{
"name": "플로우 이름 (50자 이내)",
"description": "플로우 상세 설명",
"version": "1.0",
"meta": {
"name": "플로우 이름 (50자 이내)",
"description": "상세 설명",
"tags": ["category", "integration", "crud"]
},
"config": {
"baseUrl": "https://sam.kr/api/v1",
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"testPrefix": "TEST_"
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
},
"steps": [
{
@@ -386,57 +382,69 @@ class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 te
"method": "POST",
"endpoint": "/path",
"body": { ... },
"expect": { "status": [200, 201] },
"extract": { "변수명": "$.data.id" }
"expect": {
"status": [200],
"jsonPath": { "$.access_token": "@isString" }
},
"extract": { "token": "$.access_token" }
}
]
}
## 주의사항
- meta.name: 값이 플로우 이름으로 저장됨
- meta.tags[0]: 번째 태그가 카테고리 사용
- meta.description: 값이 설명으로 저장됨
- 스텝 ID는 고유해야
- extract로 추출한 값은 이후 스텝에서 @{{stepId.변수명}}으로 사용</pre>
- name: 최상위 필드로, 플로우 이름으로 저장됨
- description: 최상위 필드로, 설명으 저장
- config.apiKey: API 인증키 설정
- variables에서 @{{$env.XXX}} 형식으로 환경변수 참조 가능
- extract로 추출한 값은 이후 스텝에서 @{{stepId.변수명}}으로 사용
- headers에 Authorization: Bearer @{{login.token}} 형식으로 토큰 전달</pre>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3">실제 예시: 품목관리 API 테스트</h4>
<h4 class="text-lg font-semibold mt-6 mb-3">실제 예시: 인증 플로우 테스트</h4>
<div class="relative">
<button onclick="copyExamplePrompt()"
class="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors z-10">
복사
</button>
<pre id="example-prompt" class="bg-gray-800 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto">품목관리(Item Master) API 통합 테스트 플로우를 만들어줘.
<pre id="example-prompt" class="bg-gray-800 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto">인증 API 테스트 플로우를 만들어줘.
## 테스트 목적
품목관리의 페이지, 섹션, 필드 CRUD 연결/해제 기능 검증
로그인, 프로필 조회, 토큰 갱신, 로그아웃 전체 인증 플로우 검증
## API 서버
- Base URL: https://sam.kr/api/v1
- 인증: Bearer Token
- Base URL: https://api.sam.kr/api/v1
- 인증: user_id/user_pwd access_token/refresh_token
## 테스트 시나리오
1. 페이지 생성 삭제 다시 생성
2. 섹션 독립 생성 삭제 다시 생성
3. 필드 독립 생성 삭제 다시 생성
4. 페이지에 섹션 연결 연결 해제 다시 연결
5. 섹션에 필드 연결 연결 해제
6. 정리: 생성된 리소스 삭제
1. 로그인 access_token, refresh_token 추출
2. 프로필 조회 Authorization 헤더에 access_token 사용
3. 토큰 갱신 refresh_token으로 access_token 발급
4. 로그아웃 access_token으로 로그아웃
## API 엔드포인트
- POST /item-master/pages - 페이지 생성
- DELETE /item-master/pages/{id} - 페이지 삭제
- POST /item-master/sections - 섹션 독립 생성
- POST /item-master/pages/{id}/link-section - 섹션 연결
- POST /item-master/sections/{id}/link-field - 필드 연결
- POST /login - { user_id, user_pwd } { access_token, refresh_token }
- GET /users/me - Authorization: Bearer {token} { success: true }
- POST /refresh - { refresh_token } { access_token }
- POST /logout - Authorization: Bearer {token}
## JSON 형식 (meta 포함 필수!)
- meta.name: "품목관리 API 통합 테스트"
- meta.tags: ["item-master", "integration", "crud"]
- meta.description: "페이지/섹션/필드 CRUD 및 연결 기능 검증"
## JSON 형식
{
"name": "인증 플로우 테스트",
"description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.",
"version": "1.0",
"config": {
"baseUrl": "https://api.sam.kr/api/v1",
"apiKey": "YOUR_API_KEY",
"timeout": 30000,
"stopOnFailure": true
},
"variables": {
"user_id": "@{{$env.FLOW_TESTER_USER_ID}}",
"user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
}
}
API 응답에서 생성된 ID를 추출해서 다음 단계에서 사용해줘.
테스트 데이터는 "TEST_" 접두사를 붙여줘.</pre>
extract로 token을 추출하고 다음 스텝 headers에서 사용해줘.</pre>
</div>
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">

View File

@@ -84,80 +84,228 @@ class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
@endif
</div>
<!-- 실행 로그 -->
<!-- 실행 로그 API 로그 () -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">실행 로그</h2>
<div class="bg-white rounded-lg shadow-sm" x-data="{ activeTab: 'execution' }">
<!-- 헤더 -->
<div class="flex border-b">
<button @click="activeTab = 'execution'"
:class="activeTab === 'execution' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors">
실행 로그
@if($run->execution_log)
<span class="ml-1 text-xs text-gray-400">({{ count($run->execution_log) }})</span>
@endif
</button>
<button @click="activeTab = 'api'"
:class="activeTab === 'api' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2">
API 로그
@if($run->api_logs)
<span class="text-xs text-gray-400">({{ count($run->api_logs) }})</span>
@if($run->hasApiErrors())
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
@endif
@endif
</button>
</div>
@if($run->execution_log)
<div class="space-y-4">
@foreach($run->execution_log as $index => $log)
<div class="border rounded-lg p-4 {{ ($log['success'] ?? false) ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' }}">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
@if($log['success'] ?? false)
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<!-- 실행 로그 -->
<div x-show="activeTab === 'execution'" class="p-6">
@if($run->execution_log)
<div class="space-y-3">
@foreach($run->execution_log as $index => $log)
<div x-data="{ expanded: {{ ($log['success'] ?? false) ? 'false' : 'true' }} }"
class="border rounded-lg {{ ($log['success'] ?? false) ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' }}">
{{-- 헤더 (항상 표시, 클릭으로 토글) --}}
<div @click="expanded = !expanded"
class="flex justify-between items-center p-4 cursor-pointer hover:bg-opacity-80 transition">
<div class="flex items-center gap-2">
{{-- 펼침/접힘 아이콘 --}}
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
:class="expanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@else
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@endif
<span class="font-medium">{{ $log['step_id'] ?? 'Step '.($index + 1) }}</span>
<span class="text-gray-500">{{ $log['name'] ?? '' }}</span>
{{-- 성공/실패 아이콘 --}}
@if($log['success'] ?? false)
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
@else
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@endif
<span class="font-medium">{{ $log['step_id'] ?? 'Step '.($index + 1) }}</span>
<span class="text-gray-500">{{ $log['name'] ?? '' }}</span>
{{-- 간단한 요약 정보 --}}
@if(isset($log['request']))
<code class="text-xs text-gray-600 bg-white bg-opacity-50 px-2 py-0.5 rounded">
{{ $log['request']['method'] ?? '' }} {{ Str::limit($log['request']['endpoint'] ?? '', 30) }}
</code>
@endif
</div>
<div class="flex items-center gap-3">
@if(isset($log['response']['status']))
<span class="px-2 py-0.5 text-xs font-medium rounded {{ ($log['response']['status'] ?? 0) < 400 ? 'bg-green-600 text-white' : 'bg-red-600 text-white' }}">
{{ $log['response']['status'] }}
</span>
@endif
<span class="text-sm text-gray-500">{{ $log['duration'] ?? '' }}ms</span>
</div>
</div>
{{-- 상세 내용 (펼쳤을 때만 표시) --}}
<div x-show="expanded" x-collapse class="px-4 pb-4 border-t border-gray-200 border-opacity-50">
<div class="pt-3 space-y-3">
{{-- Request 상세 --}}
@if(isset($log['request']))
<div class="text-sm">
<div class="font-medium text-gray-700 mb-1">Request</div>
<div class="bg-white bg-opacity-60 rounded p-2">
<code class="text-gray-800">{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}</code>
@if(!empty($log['request']['body']))
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-500 hover:text-gray-700">요청 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto">{{ json_encode($log['request']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
@endif
</div>
</div>
@endif
{{-- Response 상세 --}}
@if(isset($log['response']))
<div class="text-sm">
<div class="font-medium text-gray-700 mb-1">Response</div>
<div class="bg-white bg-opacity-60 rounded p-2">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium rounded {{ ($log['response']['status'] ?? 0) < 400 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
{{ $log['response']['status'] ?? '-' }}
</span>
@if(isset($log['expect']['status']))
<span class="text-xs text-gray-500">
예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }}
</span>
@endif
</div>
@if(!empty($log['response']['body']))
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-500 hover:text-gray-700">응답 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto max-h-60 overflow-y-auto">{{ json_encode($log['response']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
@endif
</div>
</div>
@endif
{{-- 성공/실패 이유 --}}
@if(isset($log['reason']))
<div class="text-sm {{ ($log['success'] ?? false) ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100' }} px-3 py-2 rounded">
<span class="font-medium">{{ ($log['success'] ?? false) ? '성공' : '실패' }} 이유:</span>
{{ $log['reason'] }}
</div>
@endif
{{-- 스텝 설명 --}}
@if(isset($log['description']) && $log['description'])
<div class="text-xs text-gray-500 italic">
{{ $log['description'] }}
</div>
@endif
{{-- 에러 상세 --}}
@if(isset($log['error']))
<div class="text-sm bg-red-100 rounded p-3">
<div class="font-medium text-red-800 mb-1">에러</div>
<div class="text-red-700">{{ $log['error'] }}</div>
</div>
@endif
</div>
</div>
<span class="text-sm text-gray-500">{{ $log['duration'] ?? '' }}ms</span>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>실행 로그가 없습니다.</p>
</div>
@endif
</div>
@if(isset($log['request']))
<div class="text-sm mb-2">
<span class="font-medium text-gray-600">Request:</span>
<code class="ml-2 text-gray-800">{{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }}</code>
<!-- API 로그 -->
<div x-show="activeTab === 'api'" class="p-6">
@if($run->api_logs && count($run->api_logs) > 0)
<div class="space-y-3">
@foreach($run->api_logs as $index => $apiLog)
@php
$isRequest = ($apiLog['type'] ?? '') === 'request';
$isError = !$isRequest && (($apiLog['status'] ?? 0) >= 400 || ($apiLog['success'] ?? true) === false);
@endphp
<div class="border rounded-lg p-3 {{ $isRequest ? 'border-blue-200 bg-blue-50' : ($isError ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50') }}">
<div class="flex justify-between items-start">
<div class="flex items-center gap-2">
@if($isRequest)
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 rounded">REQ</span>
@else
<span class="px-2 py-0.5 text-xs font-medium {{ $isError ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700' }} rounded">RES</span>
@endif
@if($isRequest)
<code class="text-sm font-medium text-gray-800">
{{ $apiLog['method'] ?? '' }} {{ $apiLog['uri'] ?? '' }}
</code>
@else
<code class="text-sm text-gray-800">
{{ $apiLog['uri'] ?? '' }}
</code>
<span class="px-2 py-0.5 text-xs font-medium {{ $isError ? 'bg-red-600 text-white' : 'bg-green-600 text-white' }} rounded">
{{ $apiLog['status'] ?? '-' }}
</span>
@endif
</div>
<span class="text-xs text-gray-500">{{ $apiLog['timestamp'] ?? '' }}</span>
</div>
@endif
@if(isset($log['response']))
<div class="text-sm">
<span class="font-medium text-gray-600">Response:</span>
<span class="ml-2 {{ ($log['response']['status'] ?? 0) < 400 ? 'text-green-600' : 'text-red-600' }}">
{{ $log['response']['status'] ?? '-' }}
</span>
@if(isset($log['expect']['status']))
<span class="ml-2 text-gray-500">
(예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }})
</span>
@if($isRequest && !empty($apiLog['input']))
<div class="mt-2">
<details class="text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">요청 데이터</summary>
<pre class="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto">{{ json_encode($apiLog['input'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</details>
</div>
@endif
@if(!$isRequest)
@if(isset($apiLog['message']) && $apiLog['message'])
<div class="mt-2 text-sm {{ $isError ? 'text-red-700' : 'text-green-700' }}">
{{ $apiLog['message'] }}
</div>
@endif
</div>
@endif
{{-- 성공/실패 이유 설명 --}}
@if(isset($log['reason']))
<div class="mt-2 text-sm {{ ($log['success'] ?? false) ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100' }} px-2 py-1 rounded">
{{ $log['reason'] }}
</div>
@endif
{{-- 스텝 설명 --}}
@if(isset($log['description']) && $log['description'])
<div class="mt-1 text-xs text-gray-500 italic">
💡 {{ $log['description'] }}
</div>
@endif
@if(isset($log['error']))
<div class="mt-2 text-sm text-red-600">
{{ $log['error'] }}
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>실행 로그가 없습니다.</p>
</div>
@endif
@if(isset($apiLog['error']))
<div class="mt-2">
<details class="text-sm" open>
<summary class="cursor-pointer text-red-700 font-medium">오류 상세</summary>
<pre class="mt-1 p-2 bg-red-100 rounded text-xs overflow-x-auto text-red-800">{{ is_array($apiLog['error']) ? json_encode($apiLog['error'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : $apiLog['error'] }}</pre>
</details>
</div>
@endif
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>API 로그가 없습니다.</p>
<p class="text-xs mt-1">API 서버의 로그 파일에서 캡처됩니다.</p>
</div>
@endif
</div>
</div>
</div>
</div>