- 리스트/상세 페이지에 재전송 버튼 추가 - 인증 방식 선택: 토큰 직접 입력 / 사용자 선택(Sanctum 토큰 발급) - 환경별 API URL 변환 (API_BASE_URL 설정) - X-API-KEY 헤더 자동 추가 (FLOW_TESTER_API_KEY 사용) - 성공/실패 상태 배너 표시 - 세션에 토큰 저장하여 다음 재전송 시 자동 입력 - 재전송 성공 시 1초 후 페이지 새로고침 - 에러만 필터 옵션 추가 (4xx+5xx)
583 lines
27 KiB
PHP
583 lines
27 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'API 로그 상세')
|
|
|
|
@section('content')
|
|
<!-- 페이지 헤더 -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div class="flex items-center gap-4">
|
|
<a href="{{ route('dev-tools.api-logs.index') }}" class="text-gray-500 hover:text-gray-700">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
</svg>
|
|
</a>
|
|
<h1 class="text-2xl font-bold text-gray-800">API 로그 상세</h1>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<!-- 재전송 버튼 -->
|
|
<button onclick="openResendModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
재전송
|
|
</button>
|
|
@if($log->response_status >= 400)
|
|
<button onclick="copyAiAnalysis()" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
AI 분석용 복사
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 기본 정보 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">요청 정보</h2>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
<div>
|
|
<div class="text-sm text-gray-500">메서드</div>
|
|
<span class="px-2 py-1 text-sm font-medium rounded {{ $log->method_color }}">
|
|
{{ $log->method }}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">상태 코드</div>
|
|
<span class="px-2 py-1 text-sm font-medium rounded {{ $log->status_color }}">
|
|
{{ $log->response_status }}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">응답 시간</div>
|
|
<div class="text-lg font-semibold">{{ number_format($log->duration_ms) }}ms</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">요청 시간</div>
|
|
<div class="text-sm">{{ $log->created_at->format('Y-m-d H:i:s') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<div class="text-sm text-gray-500 mb-1">URL</div>
|
|
<div class="bg-gray-100 rounded-lg p-3 font-mono text-sm break-all">
|
|
{{ $log->url }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
<div>
|
|
<div class="text-sm text-gray-500">라우트</div>
|
|
<div class="text-sm font-mono">{{ $log->route_name ?? '-' }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">IP 주소</div>
|
|
<div class="text-sm">{{ $log->ip_address ?? '-' }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">테넌트</div>
|
|
<div class="text-sm">
|
|
@if($log->tenant)
|
|
{{ $log->tenant->company_name }} (#{{ $log->tenant_id }})
|
|
@else
|
|
<span class="text-gray-400">{{ $log->tenant_id ?? '-' }}</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">사용자</div>
|
|
<div class="text-sm">
|
|
@if($log->user)
|
|
{{ $log->user->name ?? $log->user->email }} (#{{ $log->user_id }})
|
|
@else
|
|
<span class="text-gray-400">{{ $log->user_id ? "#{$log->user_id}" : 'guest' }}</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm text-gray-500">그룹 ID</div>
|
|
<div class="text-sm">
|
|
@if($log->group_id)
|
|
<a href="{{ route('dev-tools.api-logs.index', ['group_id' => $log->group_id]) }}"
|
|
class="text-purple-600 hover:text-purple-800 font-mono text-xs">
|
|
{{ Str::limit($log->group_id, 20) }}
|
|
</a>
|
|
@else
|
|
<span class="text-gray-400">-</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 같은 그룹의 요청들 -->
|
|
@if($log->group_id && count($groupLogs) > 0)
|
|
<div class="bg-white rounded-lg shadow-sm mb-6">
|
|
<!-- 클릭 가능한 헤더 (접힌 상태 기본) -->
|
|
<div class="p-4 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
|
|
onclick="document.getElementById('groupLogsContent').classList.toggle('hidden'); document.getElementById('groupLogsChevron').classList.toggle('rotate-180')">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-lg font-semibold text-gray-800">
|
|
같은 그룹의 요청
|
|
</h2>
|
|
<!-- 메서드별 개수 뱃지 -->
|
|
<div class="flex items-center gap-2">
|
|
@foreach($groupMethodCounts as $method => $count)
|
|
@php
|
|
$methodColors = [
|
|
'GET' => 'bg-blue-100 text-blue-800',
|
|
'POST' => 'bg-green-100 text-green-800',
|
|
'PUT' => 'bg-yellow-100 text-yellow-800',
|
|
'PATCH' => 'bg-orange-100 text-orange-800',
|
|
'DELETE' => 'bg-red-100 text-red-800',
|
|
];
|
|
$color = $methodColors[$method] ?? 'bg-gray-100 text-gray-800';
|
|
@endphp
|
|
<span class="px-2 py-0.5 text-xs font-medium rounded {{ $color }}">
|
|
{{ $method }} {{ $count }}
|
|
</span>
|
|
@endforeach
|
|
<span class="px-2 py-0.5 text-xs font-medium rounded bg-gray-200 text-gray-700">
|
|
총 {{ count($groupLogs) }}개
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<a href="{{ route('dev-tools.api-logs.index', ['group_id' => $log->group_id]) }}"
|
|
class="text-sm text-purple-600 hover:text-purple-800"
|
|
onclick="event.stopPropagation()">
|
|
전체 보기
|
|
</a>
|
|
<svg id="groupLogsChevron" class="w-5 h-5 text-gray-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<!-- 펼쳐지는 내용 (기본 숨김) -->
|
|
<div id="groupLogsContent" class="hidden border-t">
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">시간</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">메서드</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">응답</th>
|
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
@foreach($groupLogs as $gLog)
|
|
<tr class="hover:bg-gray-50 {{ $gLog->response_status >= 400 ? 'bg-red-50' : '' }}">
|
|
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
|
|
{{ $gLog->created_at->format('H:i:s.v') }}
|
|
</td>
|
|
<td class="px-3 py-2 whitespace-nowrap">
|
|
<span class="px-1.5 py-0.5 text-xs font-medium rounded {{ $gLog->method_color }}">
|
|
{{ $gLog->method }}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-2 text-xs text-gray-900 max-w-xs truncate" title="{{ $gLog->url }}">
|
|
{{ $gLog->path }}
|
|
</td>
|
|
<td class="px-3 py-2 whitespace-nowrap">
|
|
<span class="px-1.5 py-0.5 text-xs font-medium rounded {{ $gLog->status_color }}">
|
|
{{ $gLog->response_status }}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
|
|
{{ number_format($gLog->duration_ms) }}ms
|
|
</td>
|
|
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
|
|
<a href="{{ route('dev-tools.api-logs.show', $gLog->id) }}" class="text-blue-600 hover:text-blue-800">
|
|
상세
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- User Agent -->
|
|
@if($log->user_agent)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">User Agent</h2>
|
|
<div class="bg-gray-100 rounded-lg p-3 font-mono text-sm break-all">
|
|
{{ $log->user_agent }}
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 요청 헤더 -->
|
|
@if($log->request_headers)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">요청 헤더</h2>
|
|
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
|
<pre class="text-green-400 text-sm font-mono">{!! stripslashes(json_encode($log->request_headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) !!}</pre>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 쿼리 파라미터 -->
|
|
@if($log->request_query && count($log->request_query) > 0)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">쿼리 파라미터</h2>
|
|
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
|
<pre class="text-green-400 text-sm font-mono">{!! stripslashes(json_encode($log->request_query, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) !!}</pre>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 요청 바디 -->
|
|
@if($log->request_body && count($log->request_body) > 0)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">요청 바디</h2>
|
|
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
|
<pre class="text-green-400 text-sm font-mono">{!! stripslashes(json_encode($log->request_body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) !!}</pre>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 응답 바디 -->
|
|
@if($log->response_body)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">응답 바디</h2>
|
|
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto max-h-96">
|
|
@php
|
|
$responseData = json_decode($log->response_body, true);
|
|
// json_decode 실패 시 유니코드 이스케이프를 한글로 변환하고 슬래시 이스케이프도 제거
|
|
if ($responseData === null) {
|
|
$displayBody = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function($m) {
|
|
return mb_convert_encoding(pack('H*', $m[1]), 'UTF-8', 'UTF-16BE');
|
|
}, $log->response_body);
|
|
$displayBody = str_replace('\\/', '/', $displayBody);
|
|
}
|
|
@endphp
|
|
@if($responseData)
|
|
<pre class="text-green-400 text-sm font-mono">{!! stripslashes(json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) !!}</pre>
|
|
@else
|
|
<pre class="text-green-400 text-sm font-mono">{!! e($displayBody) !!}</pre>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if($log->response_status >= 400)
|
|
<!-- AI 분석용 텍스트 (숨김) -->
|
|
<textarea id="aiAnalysisText" class="hidden">{!! $log->ai_analysis_summary !!}</textarea>
|
|
@endif
|
|
|
|
<!-- 재전송 모달 -->
|
|
<div id="resendModal" class="fixed inset-0 z-50 hidden">
|
|
<div class="fixed inset-0 bg-black/50" onclick="closeResendModal()"></div>
|
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
|
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 relative max-h-[90vh] overflow-y-auto">
|
|
<!-- 헤더 -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900">API 재전송</h3>
|
|
<button onclick="closeResendModal()" class="text-gray-400 hover:text-gray-600">
|
|
<svg class="w-6 h-6" 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>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 요청 정보 미리보기 -->
|
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="px-2 py-1 text-xs font-medium rounded {{ $log->method_color }}">{{ $log->method }}</span>
|
|
<span class="text-sm text-gray-600 truncate">{{ $log->url }}</span>
|
|
</div>
|
|
@if($log->request_body && count($log->request_body) > 0)
|
|
<div class="text-xs text-gray-500 mt-2">
|
|
<span class="font-medium">요청 바디:</span>
|
|
<code class="block mt-1 bg-gray-100 p-2 rounded overflow-x-auto">{{ Str::limit(json_encode($log->request_body, JSON_UNESCAPED_UNICODE), 200) }}</code>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- 인증 방식 선택 -->
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">인증 방식</label>
|
|
<div class="flex gap-4">
|
|
<label class="flex items-center">
|
|
<input type="radio" name="authType" value="token" checked onchange="toggleAuthType()" class="mr-2">
|
|
<span class="text-sm">토큰 직접 입력</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" name="authType" value="user" onchange="toggleAuthType()" class="mr-2">
|
|
<span class="text-sm">사용자 선택</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 토큰 직접 입력 -->
|
|
<div id="tokenInputSection" class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
|
|
<input type="text" id="bearerToken" placeholder="Bearer 토큰을 입력하세요"
|
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
value="{{ $savedToken ?? '' }}">
|
|
@if($savedToken ?? false)
|
|
<p class="text-xs text-green-600 mt-1">✅ 세션에 저장된 토큰이 자동으로 입력되었습니다.</p>
|
|
@else
|
|
<p class="text-xs text-gray-500 mt-1">원본 요청의 토큰을 사용하거나 새 토큰을 입력하세요.</p>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- 사용자 선택 -->
|
|
<div id="userSelectSection" class="mb-4 hidden">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 사용자 선택</label>
|
|
<select id="selectedUser" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="">사용자를 선택하세요</option>
|
|
</select>
|
|
<p class="text-xs text-gray-500 mt-1">선택한 사용자로 임시 토큰이 발급됩니다.</p>
|
|
<div id="userLoadingSpinner" class="hidden mt-2 text-sm text-gray-500">
|
|
<svg class="animate-spin h-4 w-4 inline mr-1" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
사용자 목록 로딩 중...
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 입력 에러 배너 -->
|
|
<div id="resendErrorBanner" class="mb-4 p-3 rounded-lg bg-yellow-100 border border-yellow-300 text-yellow-800 hidden">
|
|
<span id="resendErrorText"></span>
|
|
</div>
|
|
|
|
<!-- 결과 표시 영역 -->
|
|
<div id="resendResult" class="mb-4 hidden">
|
|
<!-- 상태 배너 -->
|
|
<div id="resendStatusBanner" class="mb-3 p-3 rounded-lg flex items-center gap-2">
|
|
<svg id="resendStatusIcon" class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"></svg>
|
|
<span id="resendStatusText" class="font-medium"></span>
|
|
</div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">응답 결과</label>
|
|
<div id="resendResultContent" class="bg-gray-900 rounded-lg p-4 overflow-x-auto max-h-64">
|
|
<pre class="text-green-400 text-sm font-mono"></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 버튼 -->
|
|
<div class="flex justify-end gap-3">
|
|
<button type="button" onclick="closeResendModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
|
닫기
|
|
</button>
|
|
<button type="button" id="resendBtn" onclick="executeResend()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
<span>재전송 실행</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 모달 열기
|
|
function openResendModal() {
|
|
document.getElementById('resendModal').classList.remove('hidden');
|
|
document.getElementById('resendResult').classList.add('hidden');
|
|
document.getElementById('resendErrorBanner')?.classList.add('hidden');
|
|
// 저장된 토큰이 있으면 자동 채우기
|
|
document.getElementById('bearerToken').value = savedBearerToken || '';
|
|
// 기본값으로 토큰 입력 선택
|
|
document.querySelector('input[name="authType"][value="token"]').checked = true;
|
|
toggleAuthType();
|
|
}
|
|
|
|
// 모달 닫기
|
|
function closeResendModal() {
|
|
document.getElementById('resendModal').classList.add('hidden');
|
|
}
|
|
|
|
// 에러 표시
|
|
function showResendError(message) {
|
|
const banner = document.getElementById('resendErrorBanner');
|
|
const text = document.getElementById('resendErrorText');
|
|
text.textContent = '⚠️ ' + message;
|
|
banner.classList.remove('hidden');
|
|
}
|
|
|
|
// 인증 방식 토글
|
|
function toggleAuthType() {
|
|
const authType = document.querySelector('input[name="authType"]:checked').value;
|
|
const tokenSection = document.getElementById('tokenInputSection');
|
|
const userSection = document.getElementById('userSelectSection');
|
|
|
|
if (authType === 'token') {
|
|
tokenSection.classList.remove('hidden');
|
|
userSection.classList.add('hidden');
|
|
} else {
|
|
tokenSection.classList.add('hidden');
|
|
userSection.classList.remove('hidden');
|
|
loadTenantUsers();
|
|
}
|
|
}
|
|
|
|
// 테넌트 사용자 목록 로드
|
|
let usersLoaded = false;
|
|
let savedBearerToken = '{{ $savedToken ?? '' }}'; // 세션 토큰 또는 발급된 토큰
|
|
function loadTenantUsers() {
|
|
if (usersLoaded) return;
|
|
|
|
const spinner = document.getElementById('userLoadingSpinner');
|
|
const select = document.getElementById('selectedUser');
|
|
spinner.classList.remove('hidden');
|
|
|
|
fetch('/api/admin/users?tenant_id={{ $log->tenant_id ?? 1 }}&per_page=100', {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
spinner.classList.add('hidden');
|
|
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
|
|
|
|
const users = data.data || data;
|
|
users.forEach(user => {
|
|
const option = document.createElement('option');
|
|
option.value = user.id;
|
|
option.textContent = `${user.name || user.email} (${user.email})`;
|
|
select.appendChild(option);
|
|
});
|
|
usersLoaded = true;
|
|
})
|
|
.catch(err => {
|
|
spinner.classList.add('hidden');
|
|
console.error('사용자 목록 로드 실패:', err);
|
|
select.innerHTML = '<option value="">로드 실패 - 다시 시도해주세요</option>';
|
|
});
|
|
}
|
|
|
|
// 재전송 실행
|
|
function executeResend() {
|
|
const btn = document.getElementById('resendBtn');
|
|
const resultDiv = document.getElementById('resendResult');
|
|
const resultContent = document.getElementById('resendResultContent').querySelector('pre');
|
|
|
|
const authType = document.querySelector('input[name="authType"]:checked').value;
|
|
let payload = { log_id: {{ $log->id }} };
|
|
|
|
if (authType === 'token') {
|
|
const token = document.getElementById('bearerToken').value.trim();
|
|
if (!token) {
|
|
showResendError('Bearer 토큰을 입력해주세요.');
|
|
return;
|
|
}
|
|
payload.token = token;
|
|
} else {
|
|
const userId = document.getElementById('selectedUser').value;
|
|
if (!userId) {
|
|
showResendError('사용자를 선택해주세요.');
|
|
return;
|
|
}
|
|
payload.user_id = userId;
|
|
}
|
|
|
|
// 에러 배너 숨기기
|
|
document.getElementById('resendErrorBanner')?.classList.add('hidden');
|
|
|
|
// 버튼 로딩 상태
|
|
btn.disabled = true;
|
|
btn.innerHTML = `
|
|
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<span>전송 중...</span>
|
|
`;
|
|
|
|
fetch('{{ route("dev-tools.api-logs.resend", $log->id) }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
|
},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
const banner = document.getElementById('resendStatusBanner');
|
|
const statusIcon = document.getElementById('resendStatusIcon');
|
|
const statusText = document.getElementById('resendStatusText');
|
|
|
|
const isSuccess = data.status >= 200 && data.status < 400;
|
|
|
|
if (isSuccess) {
|
|
banner.className = 'mb-3 p-3 rounded-lg flex items-center gap-2 bg-green-100 border border-green-300';
|
|
statusIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
|
|
statusIcon.className = 'w-5 h-5 flex-shrink-0 text-green-600';
|
|
statusText.className = 'font-medium text-green-800';
|
|
statusText.textContent = `✅ 성공! (${data.status}) - ${data.duration_ms}ms`;
|
|
resultContent.className = 'text-green-400 text-sm font-mono whitespace-pre-wrap';
|
|
} else {
|
|
banner.className = 'mb-3 p-3 rounded-lg flex items-center gap-2 bg-red-100 border border-red-300';
|
|
statusIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>';
|
|
statusIcon.className = 'w-5 h-5 flex-shrink-0 text-red-600';
|
|
statusText.className = 'font-medium text-red-800';
|
|
statusText.textContent = `❌ 실패 (${data.status}) - ${data.duration_ms}ms`;
|
|
resultContent.className = 'text-red-400 text-sm font-mono whitespace-pre-wrap';
|
|
}
|
|
|
|
let resultText = '';
|
|
if (typeof data.response === 'object') {
|
|
resultText = JSON.stringify(data.response, null, 2);
|
|
} else {
|
|
resultText = data.response;
|
|
}
|
|
resultContent.textContent = resultText;
|
|
})
|
|
.catch(err => {
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
const banner = document.getElementById('resendStatusBanner');
|
|
const statusIcon = document.getElementById('resendStatusIcon');
|
|
const statusText = document.getElementById('resendStatusText');
|
|
|
|
banner.className = 'mb-3 p-3 rounded-lg flex items-center gap-2 bg-red-100 border border-red-300';
|
|
statusIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>';
|
|
statusIcon.className = 'w-5 h-5 flex-shrink-0 text-red-600';
|
|
statusText.className = 'font-medium text-red-800';
|
|
statusText.textContent = '❌ 요청 실패: 네트워크 오류';
|
|
|
|
resultContent.className = 'text-red-400 text-sm font-mono';
|
|
resultContent.textContent = err.message;
|
|
})
|
|
.finally(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
<span>재전송 실행</span>
|
|
`;
|
|
});
|
|
}
|
|
|
|
@if($log->response_status >= 400)
|
|
function copyAiAnalysis() {
|
|
const text = document.getElementById('aiAnalysisText').value;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
alert('AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.');
|
|
}).catch(err => {
|
|
console.error('복사 실패:', err);
|
|
alert('복사에 실패했습니다.');
|
|
});
|
|
}
|
|
@endif
|
|
</script>
|
|
@endsection
|