feat: API 로그 재전송 기능 추가
- 리스트/상세 페이지에 재전송 버튼 추가 - 인증 방식 선택: 토큰 직접 입력 / 사용자 선택(Sanctum 토큰 발급) - 환경별 API URL 변환 (API_BASE_URL 설정) - X-API-KEY 헤더 자동 추가 (FLOW_TESTER_API_KEY 사용) - 성공/실패 상태 배너 표시 - 세션에 토큰 저장하여 다음 재전송 시 자동 입력 - 재전송 성공 시 1초 후 페이지 새로고침 - 에러만 필터 옵션 추가 (4xx+5xx)
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ApiLogController extends Controller
|
||||
@@ -35,6 +38,8 @@ public function index(Request $request): View
|
||||
$status = $request->status;
|
||||
if ($status === '2xx') {
|
||||
$query->whereBetween('response_status', [200, 299]);
|
||||
} elseif ($status === 'error') {
|
||||
$query->where('response_status', '>=', 400);
|
||||
} elseif ($status === '4xx') {
|
||||
$query->whereBetween('response_status', [400, 499]);
|
||||
} elseif ($status === '5xx') {
|
||||
@@ -44,7 +49,7 @@ public function index(Request $request): View
|
||||
|
||||
// 필터: URL 검색
|
||||
if ($request->filled('search')) {
|
||||
$query->where('url', 'like', '%' . $request->search . '%');
|
||||
$query->where('url', 'like', '%'.$request->search.'%');
|
||||
}
|
||||
|
||||
// 필터: 그룹 ID
|
||||
@@ -71,7 +76,7 @@ public function index(Request $request): View
|
||||
// 현재 페이지 로그들의 그룹별 개수 조회
|
||||
$groupIds = $logs->pluck('group_id')->filter()->unique()->values()->toArray();
|
||||
$groupCounts = [];
|
||||
if (!empty($groupIds)) {
|
||||
if (! empty($groupIds)) {
|
||||
$groupCounts = ApiRequestLog::whereIn('group_id', $groupIds)
|
||||
->selectRaw('group_id, COUNT(*) as count')
|
||||
->groupBy('group_id')
|
||||
@@ -79,7 +84,11 @@ public function index(Request $request): View
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return view('api-logs.index', compact('logs', 'stats', 'groupCounts'));
|
||||
// 세션에 저장된 토큰 정보
|
||||
$savedToken = session('api_resend_token');
|
||||
$savedUserId = session('api_resend_user_id');
|
||||
|
||||
return view('api-logs.index', compact('logs', 'stats', 'groupCounts', 'savedToken', 'savedUserId'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,12 +109,16 @@ public function show(int $id): View
|
||||
|
||||
// 메서드별 개수 집계
|
||||
$groupMethodCounts = $groupLogs->groupBy('method')
|
||||
->map(fn($items) => $items->count())
|
||||
->map(fn ($items) => $items->count())
|
||||
->sortKeys()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return view('api-logs.show', compact('log', 'groupLogs', 'groupMethodCounts'));
|
||||
// 세션에 저장된 토큰 정보
|
||||
$savedToken = session('api_resend_token');
|
||||
$savedUserId = session('api_resend_user_id');
|
||||
|
||||
return view('api-logs.show', compact('log', 'groupLogs', 'groupMethodCounts', 'savedToken', 'savedUserId'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,4 +142,124 @@ public function truncate()
|
||||
return redirect()->route('dev-tools.api-logs.index')
|
||||
->with('success', '모든 로그가 삭제되었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 로그 재전송
|
||||
*/
|
||||
public function resend(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$log = ApiRequestLog::findOrFail($id);
|
||||
|
||||
// URL 변환: api.sam.kr → API_BASE_URL (환경별 설정)
|
||||
$url = $this->convertApiUrl($log->url);
|
||||
|
||||
// 인증 토큰 결정
|
||||
$token = null;
|
||||
if ($request->filled('token')) {
|
||||
// 직접 입력한 토큰 사용
|
||||
$token = $request->input('token');
|
||||
// 세션에 저장 (다음 재전송 시 재사용)
|
||||
session(['api_resend_token' => $token]);
|
||||
} elseif ($request->filled('user_id')) {
|
||||
// 사용자 선택 시 Sanctum 토큰 발급
|
||||
$user = User::findOrFail($request->input('user_id'));
|
||||
$token = $user->createToken('api-log-resend', ['*'])->plainTextToken;
|
||||
// 세션에 저장 (다음 재전송 시 재사용)
|
||||
session(['api_resend_token' => $token]);
|
||||
} elseif (session('api_resend_token')) {
|
||||
// 세션에 저장된 토큰 사용
|
||||
$token = session('api_resend_token');
|
||||
}
|
||||
|
||||
// HTTP 요청 빌더 생성
|
||||
$httpClient = Http::timeout(30);
|
||||
|
||||
// Authorization 헤더 설정
|
||||
if ($token) {
|
||||
$httpClient = $httpClient->withToken($token);
|
||||
}
|
||||
|
||||
// Content-Type 설정 (원본 헤더에서 추출)
|
||||
$contentType = 'application/json';
|
||||
if (isset($log->request_headers['content-type'])) {
|
||||
$contentType = is_array($log->request_headers['content-type'])
|
||||
? $log->request_headers['content-type'][0]
|
||||
: $log->request_headers['content-type'];
|
||||
}
|
||||
$httpClient = $httpClient->contentType($contentType);
|
||||
|
||||
// Accept 헤더 설정
|
||||
$httpClient = $httpClient->accept('application/json');
|
||||
|
||||
// X-API-KEY 헤더 설정 (.env에서 가져옴)
|
||||
$apiKey = config('services.api.key');
|
||||
if ($apiKey) {
|
||||
$httpClient = $httpClient->withHeaders(['X-API-KEY' => $apiKey]);
|
||||
}
|
||||
|
||||
// 요청 실행 및 시간 측정
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$response = match (strtoupper($log->method)) {
|
||||
'GET' => $httpClient->get($url, $log->request_query ?? []),
|
||||
'POST' => $httpClient->post($url, $log->request_body ?? []),
|
||||
'PUT' => $httpClient->put($url, $log->request_body ?? []),
|
||||
'PATCH' => $httpClient->patch($url, $log->request_body ?? []),
|
||||
'DELETE' => $httpClient->delete($url, $log->request_body ?? []),
|
||||
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$log->method}"),
|
||||
};
|
||||
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// 응답 파싱
|
||||
$responseBody = $response->json() ?? $response->body();
|
||||
|
||||
return response()->json([
|
||||
'success' => $response->successful(),
|
||||
'status' => $response->status(),
|
||||
'duration_ms' => $durationMs,
|
||||
'response' => $responseBody,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'status' => 0,
|
||||
'duration_ms' => $durationMs,
|
||||
'response' => [
|
||||
'error' => $e->getMessage(),
|
||||
'type' => get_class($e),
|
||||
],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API URL 변환 (환경별 설정 적용)
|
||||
* 로그에 저장된 외부 URL을 현재 환경에서 접근 가능한 URL로 변환
|
||||
*/
|
||||
private function convertApiUrl(string $originalUrl): string
|
||||
{
|
||||
$apiBaseUrl = config('services.api.base_url');
|
||||
|
||||
if (empty($apiBaseUrl)) {
|
||||
return $originalUrl;
|
||||
}
|
||||
|
||||
// 기존 API 도메인 패턴 (api.sam.kr, api.codebridge-x.com 등)
|
||||
$patterns = [
|
||||
'#^https?://api\.sam\.kr#',
|
||||
'#^https?://api\.codebridge-x\.com#',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $originalUrl)) {
|
||||
return preg_replace($pattern, rtrim($apiBaseUrl, '/'), $originalUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return $originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,18 @@
|
||||
'storage_bucket' => env('GOOGLE_STORAGE_BUCKET'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SAM API Server
|
||||
|--------------------------------------------------------------------------
|
||||
| API 로그 재전송 시 사용할 API 서버 URL
|
||||
| - 로컬 Docker: http://api:80
|
||||
| - 개발 서버: https://api.codebridge-x.com
|
||||
| - 운영 서버: https://api.sam.kr
|
||||
*/
|
||||
'api' => [
|
||||
'base_url' => env('API_BASE_URL'),
|
||||
'key' => env('FLOW_TESTER_API_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 확인 모달 -->
|
||||
<div id="confirmModal" class="fixed inset-0 z-50 hidden">
|
||||
<!-- 확인 모달 (z-60으로 재전송 모달보다 위) -->
|
||||
<div id="confirmModal" class="fixed inset-0 z-[60] hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="hideConfirmModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full p-6 relative">
|
||||
@@ -98,6 +98,7 @@
|
||||
<select name="status" class="border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="2xx" {{ request('status') === '2xx' ? 'selected' : '' }}>성공 (2xx)</option>
|
||||
<option value="error" {{ request('status') === 'error' ? 'selected' : '' }}>에러만 (4xx+5xx)</option>
|
||||
<option value="4xx" {{ request('status') === '4xx' ? 'selected' : '' }}>클라이언트 에러 (4xx)</option>
|
||||
<option value="5xx" {{ request('status') === '5xx' ? 'selected' : '' }}>서버 에러 (5xx)</option>
|
||||
</select>
|
||||
@@ -215,6 +216,11 @@ class="text-purple-600 hover:text-purple-800" title="{{ $log->group_id }} ({{ $g
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm align-middle" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick="openResendModal({{ $log->id }}, '{{ $log->method }}', '{{ addslashes($log->url) }}', {{ $log->tenant_id ?? 1 }})" class="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded" title="재전송">
|
||||
<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>
|
||||
</button>
|
||||
@if($log->response_status >= 400)
|
||||
<button onclick="copyAiAnalysis({{ $log->id }})" class="p-1 text-purple-600 hover:text-purple-800 hover:bg-purple-50 rounded" title="AI 분석용 복사">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -222,7 +228,7 @@ class="text-purple-600 hover:text-purple-800" title="{{ $log->group_id }} ({{ $g
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<a href="{{ route('dev-tools.api-logs.show', $log->id) }}" class="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded" title="상세 보기">
|
||||
<a href="{{ route('dev-tools.api-logs.show', $log->id) }}" class="p-1 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded" title="상세 보기">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
@@ -390,4 +396,295 @@ function showAlertModal(title, message, color = 'blue') {
|
||||
<textarea id="ai-analysis-{{ $log->id }}" class="hidden">{!! $log->ai_analysis_summary !!}</textarea>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<!-- 재전송 모달 -->
|
||||
<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 id="resendMethod" class="px-2 py-1 text-xs font-medium rounded"></span>
|
||||
<span id="resendUrl" class="text-sm text-gray-600 truncate"></span>
|
||||
</div>
|
||||
</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="resendAuthType" value="token" checked onchange="toggleResendAuthType()" class="mr-2">
|
||||
<span class="text-sm">토큰 직접 입력</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" name="resendAuthType" value="user" onchange="toggleResendAuthType()" class="mr-2">
|
||||
<span class="text-sm">사용자 선택</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토큰 직접 입력 -->
|
||||
<div id="resendTokenSection" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
|
||||
<input type="text" id="resendBearerToken" 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="mt-1 text-xs text-green-600">✅ 세션에 저장된 토큰이 자동으로 입력되었습니다.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 사용자 선택 -->
|
||||
<div id="resendUserSection" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 사용자 선택</label>
|
||||
<select id="resendSelectedUser" 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>
|
||||
<div id="resendUserSpinner" 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 12h4z"></path>
|
||||
</svg>
|
||||
사용자 목록 로딩 중...
|
||||
</div>
|
||||
</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>
|
||||
// 재전송 모달 관련 변수
|
||||
let currentLogId = null;
|
||||
let currentTenantId = null;
|
||||
let resendUsersLoaded = {};
|
||||
let savedBearerToken = '{{ $savedToken ?? '' }}'; // 세션 토큰 또는 발급된 토큰
|
||||
|
||||
// 메서드별 색상
|
||||
const methodColors = {
|
||||
'GET': 'bg-green-100 text-green-800',
|
||||
'POST': 'bg-blue-100 text-blue-800',
|
||||
'PUT': 'bg-yellow-100 text-yellow-800',
|
||||
'PATCH': 'bg-yellow-100 text-yellow-800',
|
||||
'DELETE': 'bg-red-100 text-red-800'
|
||||
};
|
||||
|
||||
function openResendModal(logId, method, url, tenantId) {
|
||||
currentLogId = logId;
|
||||
currentTenantId = tenantId;
|
||||
|
||||
// 요청 정보 표시
|
||||
const methodSpan = document.getElementById('resendMethod');
|
||||
methodSpan.textContent = method;
|
||||
methodSpan.className = `px-2 py-1 text-xs font-medium rounded ${methodColors[method] || 'bg-gray-100 text-gray-800'}`;
|
||||
document.getElementById('resendUrl').textContent = url;
|
||||
|
||||
// 초기화
|
||||
document.getElementById('resendResult').classList.add('hidden');
|
||||
// 저장된 토큰이 있으면 자동 채우기
|
||||
document.getElementById('resendBearerToken').value = savedBearerToken || '';
|
||||
document.querySelector('input[name="resendAuthType"][value="token"]').checked = true;
|
||||
toggleResendAuthType();
|
||||
|
||||
document.getElementById('resendModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResendModal() {
|
||||
document.getElementById('resendModal').classList.add('hidden');
|
||||
currentLogId = null;
|
||||
}
|
||||
|
||||
function toggleResendAuthType() {
|
||||
const authType = document.querySelector('input[name="resendAuthType"]:checked').value;
|
||||
const tokenSection = document.getElementById('resendTokenSection');
|
||||
const userSection = document.getElementById('resendUserSection');
|
||||
|
||||
if (authType === 'token') {
|
||||
tokenSection.classList.remove('hidden');
|
||||
userSection.classList.add('hidden');
|
||||
} else {
|
||||
tokenSection.classList.add('hidden');
|
||||
userSection.classList.remove('hidden');
|
||||
loadResendUsers();
|
||||
}
|
||||
}
|
||||
|
||||
function loadResendUsers() {
|
||||
if (resendUsersLoaded[currentTenantId]) return;
|
||||
|
||||
const spinner = document.getElementById('resendUserSpinner');
|
||||
const select = document.getElementById('resendSelectedUser');
|
||||
spinner.classList.remove('hidden');
|
||||
|
||||
fetch(`/api/admin/users?tenant_id=${currentTenantId}&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);
|
||||
});
|
||||
resendUsersLoaded[currentTenantId] = true;
|
||||
})
|
||||
.catch(err => {
|
||||
spinner.classList.add('hidden');
|
||||
console.error('사용자 목록 로드 실패:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function executeResend() {
|
||||
if (!currentLogId) return;
|
||||
|
||||
const btn = document.getElementById('resendBtn');
|
||||
const resultDiv = document.getElementById('resendResult');
|
||||
const resultContent = document.getElementById('resendResultContent').querySelector('pre');
|
||||
|
||||
const authType = document.querySelector('input[name="resendAuthType"]:checked').value;
|
||||
let payload = { log_id: currentLogId };
|
||||
|
||||
if (authType === 'token') {
|
||||
const token = document.getElementById('resendBearerToken').value.trim();
|
||||
if (!token) {
|
||||
showAlertModal('입력 필요', 'Bearer 토큰을 입력해주세요.', 'yellow');
|
||||
return;
|
||||
}
|
||||
payload.token = token;
|
||||
} else {
|
||||
const userId = document.getElementById('resendSelectedUser').value;
|
||||
if (!userId) {
|
||||
showAlertModal('선택 필요', '사용자를 선택해주세요.', 'yellow');
|
||||
return;
|
||||
}
|
||||
payload.user_id = userId;
|
||||
}
|
||||
|
||||
// 버튼 로딩 상태
|
||||
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 12h4z"></path>
|
||||
</svg>
|
||||
<span>전송 중...</span>
|
||||
`;
|
||||
|
||||
fetch(`/dev-tools/api-logs/${currentLogId}/resend`, {
|
||||
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';
|
||||
// 1초 후 페이지 새로고침 (새 로그 표시)
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} 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>
|
||||
`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -13,14 +13,23 @@
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">API 로그 상세</h1>
|
||||
</div>
|
||||
@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 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>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
@@ -262,8 +271,303 @@ class="text-sm text-purple-600 hover:text-purple-800"
|
||||
@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(() => {
|
||||
@@ -273,6 +577,6 @@ function copyAiAnalysis() {
|
||||
alert('복사에 실패했습니다.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endif
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
Route::post('/prune', [ApiLogController::class, 'prune'])->name('prune');
|
||||
Route::post('/truncate', [ApiLogController::class, 'truncate'])->name('truncate');
|
||||
Route::get('/{id}', [ApiLogController::class, 'show'])->name('show');
|
||||
Route::post('/{id}/resend', [ApiLogController::class, 'resend'])->name('resend');
|
||||
});
|
||||
|
||||
// API 플로우 테스터
|
||||
|
||||
Reference in New Issue
Block a user