API 로그 목록 UI 개선
- 파라미터 표시: URL 아래에 key=value 형식으로 표시 - 아코디언 기능: row 클릭 시 요청 헤더/응답 바디 확장 표시 - 요청 헤더: 각 항목별 한 줄 포맷 (key: value) - 아이콘 버튼: 상세(눈), AI 복사(복사) 아이콘으로 변경 - AI 복사: 에러(400+) 로그에만 AI 분석 복사 버튼 표시 - 레이아웃: 테이블 컬럼 고정 너비, 셀 중간 정렬 - 아코디언 스타일: 테두리 추가, 배경색 조정 - 모든 로그 삭제(truncate) 기능 추가
This commit is contained in:
@@ -60,7 +60,18 @@ public function index(Request $request): View
|
||||
|
||||
$logs = $query->paginate(50)->withQueryString();
|
||||
|
||||
return view('api-logs.index', compact('logs', 'stats'));
|
||||
// 현재 페이지 로그들의 그룹별 개수 조회
|
||||
$groupIds = $logs->pluck('group_id')->filter()->unique()->values()->toArray();
|
||||
$groupCounts = [];
|
||||
if (!empty($groupIds)) {
|
||||
$groupCounts = ApiRequestLog::whereIn('group_id', $groupIds)
|
||||
->selectRaw('group_id, COUNT(*) as count')
|
||||
->groupBy('group_id')
|
||||
->pluck('count', 'group_id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return view('api-logs.index', compact('logs', 'stats', 'groupCounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +86,7 @@ public function show(int $id): View
|
||||
if ($log->group_id) {
|
||||
$groupLogs = ApiRequestLog::where('group_id', $log->group_id)
|
||||
->where('id', '!=', $log->id)
|
||||
->orderBy('created_at')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -92,4 +103,15 @@ public function prune()
|
||||
return redirect()->route('dev-tools.api-logs.index')
|
||||
->with('success', "{$deleted}개의 로그가 삭제되었습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 로그 삭제
|
||||
*/
|
||||
public function truncate()
|
||||
{
|
||||
$deleted = ApiRequestLog::truncate();
|
||||
|
||||
return redirect()->route('dev-tools.api-logs.index')
|
||||
->with('success', '모든 로그가 삭제되었습니다.');
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,16 @@
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">API 요청 로그</h1>
|
||||
<div class="flex gap-2">
|
||||
<form action="{{ route('dev-tools.api-logs.prune') }}" method="POST" onsubmit="return confirm('하루 지난 로그를 삭제하시겠습니까?')">
|
||||
<form action="{{ route('dev-tools.api-logs.prune') }}" method="POST" onsubmit="return confirm('하루 지난 로그를 삭제하시겠습니까?')" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg transition">
|
||||
오래된 로그 삭제
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ route('dev-tools.api-logs.truncate') }}" method="POST" onsubmit="return confirm('모든 로그를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition">
|
||||
오래된 로그 삭제
|
||||
모든 로그 삭제
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -97,72 +103,145 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
|
||||
<!-- 로그 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-fixed">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">시간</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">메서드</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-20">시간</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-20">메서드</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">응답</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">테넌트</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">사용자</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">그룹</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-16">상태</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-20">응답</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-24">테넌트</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-24">사용자</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-16">그룹</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($logs as $log)
|
||||
<tr class="hover:bg-gray-50 {{ $log->response_status >= 400 ? 'bg-red-50' : '' }}">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer h-14 {{ $log->response_status >= 400 ? 'bg-red-50' : '' }}"
|
||||
onclick="toggleAccordion({{ $log->id }})">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 align-middle">
|
||||
{{ $log->created_at->format('H:i:s') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<td class="px-4 py-2 whitespace-nowrap align-middle">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded {{ $log->method_color }}">
|
||||
{{ $log->method }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 max-w-xs truncate" title="{{ $log->url }}">
|
||||
{{ $log->path }}
|
||||
<td class="px-4 py-2 text-sm text-gray-900 max-w-md align-middle" title="{{ $log->url }}">
|
||||
<div class="truncate">{{ $log->path }}</div>
|
||||
@php
|
||||
$params = $log->request_body ?: $log->request_query ?: [];
|
||||
if (is_array($params) && !empty($params)) {
|
||||
$paramPairs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$paramPairs[] = $key . '=' . Str::limit((string)$value, 20);
|
||||
}
|
||||
$paramSummary = implode(', ', $paramPairs);
|
||||
} else {
|
||||
$paramSummary = '';
|
||||
}
|
||||
@endphp
|
||||
@if(!empty($paramSummary))
|
||||
<div class="text-xs text-gray-500 font-mono mt-0.5" title="{{ json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) }}">
|
||||
{{ $paramSummary }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<td class="px-4 py-2 whitespace-nowrap align-middle">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded {{ $log->status_color }}">
|
||||
{{ $log->response_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 align-middle">
|
||||
{{ number_format($log->duration_ms) }}ms
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 align-middle">
|
||||
@if($log->tenant)
|
||||
<span title="{{ $log->tenant->company_name }}">{{ Str::limit($log->tenant->company_name, 10) }}</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 align-middle">
|
||||
@if($log->user)
|
||||
<span title="{{ $log->user->email }}">{{ $log->user->name ?? $log->user->email }}</span>
|
||||
@else
|
||||
<span class="text-gray-400">guest</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
@if($log->group_id)
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm align-middle" onclick="event.stopPropagation()">
|
||||
@if($log->group_id && ($groupCounts[$log->group_id] ?? 0) >= 2)
|
||||
<a href="{{ route('dev-tools.api-logs.index', ['group_id' => $log->group_id]) }}"
|
||||
class="text-purple-600 hover:text-purple-800" title="{{ $log->group_id }}">
|
||||
class="text-purple-600 hover:text-purple-800" title="{{ $log->group_id }} ({{ $groupCounts[$log->group_id] }}개)">
|
||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="text-xs ml-1">{{ $groupCounts[$log->group_id] }}</span>
|
||||
</a>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<a href="{{ route('dev-tools.api-logs.show', $log->id) }}" class="text-blue-600 hover:text-blue-800">
|
||||
상세
|
||||
</a>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm align-middle" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center gap-1">
|
||||
@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">
|
||||
<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>
|
||||
</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="상세 보기">
|
||||
<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"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 아코디언 컨텐츠 -->
|
||||
<tr id="accordion-{{ $log->id }}" class="hidden">
|
||||
<td colspan="9" class="px-4 py-4 bg-slate-50 border-t border-b border-slate-200">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- 요청 헤더 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">요청 헤더</h4>
|
||||
<div class="bg-gray-900 rounded-lg p-3 overflow-x-auto max-h-64">
|
||||
<div class="text-xs font-mono space-y-0.5">
|
||||
@foreach($log->request_headers ?? [] as $key => $values)
|
||||
<div>
|
||||
<span class="text-blue-400">{{ $key }}:</span>
|
||||
<span class="text-green-400">{{ is_array($values) ? implode(', ', $values) : $values }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 응답 바디 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">응답 바디</h4>
|
||||
<div class="bg-gray-900 rounded-lg p-3 overflow-x-auto max-h-64">
|
||||
@php
|
||||
$responseData = json_decode($log->response_body, true);
|
||||
if ($responseData !== null) {
|
||||
$displayResponse = json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
$displayResponse = 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 ?? '');
|
||||
$displayResponse = str_replace('\\/', '/', $displayResponse);
|
||||
}
|
||||
@endphp
|
||||
<pre class="text-green-400 text-xs font-mono whitespace-pre-wrap">{{ Str::limit($displayResponse, 2000) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
@@ -182,4 +261,32 @@ class="text-purple-600 hover:text-purple-800" title="{{ $log->group_id }}">
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAccordion(id) {
|
||||
const accordion = document.getElementById('accordion-' + id);
|
||||
if (accordion) {
|
||||
accordion.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function copyAiAnalysis(id) {
|
||||
const textarea = document.getElementById('ai-analysis-' + id);
|
||||
if (textarea) {
|
||||
navigator.clipboard.writeText(textarea.value).then(() => {
|
||||
alert('AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.');
|
||||
}).catch(err => {
|
||||
console.error('복사 실패:', err);
|
||||
alert('복사에 실패했습니다.');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- AI 분석용 데이터 -->
|
||||
@foreach($logs as $log)
|
||||
@if($log->response_status >= 400)
|
||||
<textarea id="ai-analysis-{{ $log->id }}" class="hidden">{!! $log->ai_analysis_summary !!}</textarea>
|
||||
@endif
|
||||
@endforeach
|
||||
@endsection
|
||||
@@ -206,11 +206,18 @@ class="text-sm font-normal text-purple-600 hover:text-purple-800 ml-2">
|
||||
<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">{{ $log->response_body }}</pre>
|
||||
<pre class="text-green-400 text-sm font-mono">{!! e($displayBody) !!}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -267,6 +267,7 @@
|
||||
Route::prefix('api-logs')->name('api-logs.')->group(function () {
|
||||
Route::get('/', [ApiLogController::class, 'index'])->name('index');
|
||||
Route::post('/prune', [ApiLogController::class, 'prune'])->name('prune');
|
||||
Route::post('/truncate', [ApiLogController::class, 'truncate'])->name('truncate');
|
||||
Route::get('/{id}', [ApiLogController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user