- 필터 적용 시 오래된 순 정렬 (시간순 추적 용이) - 상세 페이지 그룹 요청 섹션: 접힌 상태 기본, 메서드별 뱃지 - 리스트 아이콘 버튼 (상세, AI 복사), 세로 중앙 정렬 - 응답 바디 슬래시 이스케이프 처리 - 시스템 alert를 커스텀 Tailwind 모달로 교체
393 lines
20 KiB
PHP
393 lines
20 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'API 로그')
|
|
|
|
@section('content')
|
|
<!-- 페이지 헤더 -->
|
|
<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 id="pruneForm" action="{{ route('dev-tools.api-logs.prune') }}" method="POST" class="inline">
|
|
@csrf
|
|
<button type="button" onclick="showConfirmModal('prune', '오래된 로그 삭제', '하루 지난 로그를 삭제하시겠습니까?', 'yellow')" class="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg transition">
|
|
오래된 로그 삭제
|
|
</button>
|
|
</form>
|
|
<form id="truncateForm" action="{{ route('dev-tools.api-logs.truncate') }}" method="POST" class="inline">
|
|
@csrf
|
|
<button type="button" onclick="showConfirmModal('truncate', '모든 로그 삭제', '모든 로그를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.', 'red')" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition">
|
|
모든 로그 삭제
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 확인 모달 -->
|
|
<div id="confirmModal" class="fixed inset-0 z-50 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">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div id="modalIcon" class="w-10 h-10 rounded-full flex items-center justify-center">
|
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<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"/>
|
|
</svg>
|
|
</div>
|
|
<h3 id="modalTitle" class="text-lg font-semibold text-gray-900"></h3>
|
|
</div>
|
|
<p id="modalMessage" class="text-gray-600 mb-6 whitespace-pre-line"></p>
|
|
<div class="flex justify-end gap-3">
|
|
<button type="button" onclick="hideConfirmModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
|
취소
|
|
</button>
|
|
<button type="button" id="modalConfirmBtn" onclick="confirmAction()" class="px-4 py-2 text-white rounded-lg transition">
|
|
확인
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 알림 메시지 -->
|
|
@if(session('success'))
|
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
|
{{ session('success') }}
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 통계 카드 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div class="text-sm text-gray-500">전체 요청</div>
|
|
<div class="text-2xl font-bold text-gray-800">{{ number_format($stats['total']) }}</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div class="text-sm text-gray-500">성공 (2xx)</div>
|
|
<div class="text-2xl font-bold text-green-600">{{ number_format($stats['success']) }}</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div class="text-sm text-gray-500">클라이언트 에러 (4xx)</div>
|
|
<div class="text-2xl font-bold text-yellow-600">{{ number_format($stats['client_error']) }}</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div class="text-sm text-gray-500">서버 에러 (5xx)</div>
|
|
<div class="text-2xl font-bold text-red-600">{{ number_format($stats['server_error']) }}</div>
|
|
</div>
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div class="text-sm text-gray-500">평균 응답시간</div>
|
|
<div class="text-2xl font-bold text-blue-600">{{ number_format($stats['avg_duration']) }}ms</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
<form method="GET" class="flex flex-wrap gap-4 items-end">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">메서드</label>
|
|
<select name="method" class="border rounded-lg px-3 py-2 text-sm">
|
|
<option value="">전체</option>
|
|
<option value="GET" {{ request('method') === 'GET' ? 'selected' : '' }}>GET</option>
|
|
<option value="POST" {{ request('method') === 'POST' ? 'selected' : '' }}>POST</option>
|
|
<option value="PUT" {{ request('method') === 'PUT' ? 'selected' : '' }}>PUT</option>
|
|
<option value="PATCH" {{ request('method') === 'PATCH' ? 'selected' : '' }}>PATCH</option>
|
|
<option value="DELETE" {{ request('method') === 'DELETE' ? 'selected' : '' }}>DELETE</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
|
<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="4xx" {{ request('status') === '4xx' ? 'selected' : '' }}>클라이언트 에러 (4xx)</option>
|
|
<option value="5xx" {{ request('status') === '5xx' ? 'selected' : '' }}>서버 에러 (5xx)</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">URL 검색</label>
|
|
<input type="text" name="search" value="{{ request('search') }}"
|
|
placeholder="URL 검색..."
|
|
class="w-full border rounded-lg px-3 py-2 text-sm">
|
|
</div>
|
|
<div>
|
|
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm">
|
|
검색
|
|
</button>
|
|
<a href="{{ route('dev-tools.api-logs.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm ml-1">
|
|
초기화
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- 그룹 필터 안내 -->
|
|
@if(request('group_id'))
|
|
<div class="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded mb-4 flex justify-between items-center">
|
|
<span>그룹 ID: <code class="bg-blue-100 px-2 py-1 rounded">{{ request('group_id') }}</code> 필터 적용 중</span>
|
|
<a href="{{ route('dev-tools.api-logs.index') }}" class="text-blue-600 hover:text-blue-800 underline">필터 해제</a>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- 로그 목록 -->
|
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
<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 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 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 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-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-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-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-2 whitespace-nowrap text-sm text-gray-500 align-middle">
|
|
{{ number_format($log->duration_ms) }}ms
|
|
</td>
|
|
<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-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-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 }} ({{ $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-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 = stripslashes(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
|
|
<tr>
|
|
<td colspan="9" class="px-4 py-8 text-center text-gray-500">
|
|
로그가 없습니다.
|
|
</td>
|
|
</tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- 페이지네이션 -->
|
|
@if($logs->hasPages())
|
|
<div class="px-4 py-3 border-t">
|
|
{{ $logs->links() }}
|
|
</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(() => {
|
|
showAlertModal('복사 완료', 'AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.', 'green');
|
|
}).catch(err => {
|
|
console.error('복사 실패:', err);
|
|
showAlertModal('복사 실패', '복사에 실패했습니다.', 'red');
|
|
});
|
|
}
|
|
}
|
|
|
|
// 커스텀 확인 모달
|
|
let currentFormId = null;
|
|
|
|
function showConfirmModal(formId, title, message, color) {
|
|
currentFormId = formId;
|
|
|
|
const modal = document.getElementById('confirmModal');
|
|
const modalIcon = document.getElementById('modalIcon');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const modalMessage = document.getElementById('modalMessage');
|
|
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
|
|
|
modalTitle.textContent = title;
|
|
modalMessage.textContent = message;
|
|
|
|
// 색상 설정
|
|
const colors = {
|
|
yellow: { icon: 'bg-yellow-500', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
red: { icon: 'bg-red-500', btn: 'bg-red-600 hover:bg-red-700' },
|
|
green: { icon: 'bg-green-500', btn: 'bg-green-600 hover:bg-green-700' },
|
|
blue: { icon: 'bg-blue-500', btn: 'bg-blue-600 hover:bg-blue-700' }
|
|
};
|
|
const colorSet = colors[color] || colors.blue;
|
|
|
|
modalIcon.className = `w-10 h-10 rounded-full flex items-center justify-center ${colorSet.icon}`;
|
|
modalConfirmBtn.className = `px-4 py-2 text-white rounded-lg transition ${colorSet.btn}`;
|
|
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
function hideConfirmModal() {
|
|
const modal = document.getElementById('confirmModal');
|
|
modal.classList.add('hidden');
|
|
currentFormId = null;
|
|
}
|
|
|
|
function confirmAction() {
|
|
if (currentFormId) {
|
|
const form = document.getElementById(currentFormId + 'Form');
|
|
if (form) {
|
|
form.submit();
|
|
}
|
|
}
|
|
hideConfirmModal();
|
|
}
|
|
|
|
// 커스텀 알림 모달 (alert 대체)
|
|
function showAlertModal(title, message, color = 'blue') {
|
|
const modal = document.getElementById('confirmModal');
|
|
const modalIcon = document.getElementById('modalIcon');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const modalMessage = document.getElementById('modalMessage');
|
|
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
|
|
|
modalTitle.textContent = title;
|
|
modalMessage.textContent = message;
|
|
|
|
const colors = {
|
|
yellow: { icon: 'bg-yellow-500', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
red: { icon: 'bg-red-500', btn: 'bg-red-600 hover:bg-red-700' },
|
|
green: { icon: 'bg-green-500', btn: 'bg-green-600 hover:bg-green-700' },
|
|
blue: { icon: 'bg-blue-500', btn: 'bg-blue-600 hover:bg-blue-700' }
|
|
};
|
|
const colorSet = colors[color] || colors.blue;
|
|
|
|
modalIcon.className = `w-10 h-10 rounded-full flex items-center justify-center ${colorSet.icon}`;
|
|
modalConfirmBtn.className = `px-4 py-2 text-white rounded-lg transition ${colorSet.btn}`;
|
|
modalConfirmBtn.textContent = '확인';
|
|
|
|
// 알림 모드: 확인 버튼만 닫기 기능으로 변경
|
|
currentFormId = null;
|
|
|
|
modal.classList.remove('hidden');
|
|
}
|
|
</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 |