API 로그 페이지 개선: 테넌트/사용자 표시, 그룹핑, AI 분석 복사
- 테넌트/사용자 컬럼 추가 (관계 eager loading) - 그룹 ID로 연관 API 호출 필터링 및 상세 페이지에서 그룹 목록 표시 - 상태 400 이상일 때 AI 분석용 복사 버튼 추가 - Tenant 모델 네임스페이스 수정 (Tenants\Tenant)
This commit is contained in:
@@ -13,7 +13,9 @@ class ApiLogController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = ApiRequestLog::query()->orderByDesc('created_at');
|
||||
$query = ApiRequestLog::query()
|
||||
->with(['tenant', 'user'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// 필터: HTTP 메서드
|
||||
if ($request->filled('method')) {
|
||||
@@ -37,6 +39,16 @@ public function index(Request $request): View
|
||||
$query->where('url', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
// 필터: 그룹 ID
|
||||
if ($request->filled('group_id')) {
|
||||
$query->where('group_id', $request->group_id);
|
||||
}
|
||||
|
||||
// 필터: 테넌트
|
||||
if ($request->filled('tenant_id')) {
|
||||
$query->where('tenant_id', $request->tenant_id);
|
||||
}
|
||||
|
||||
// 통계
|
||||
$stats = [
|
||||
'total' => ApiRequestLog::count(),
|
||||
@@ -56,9 +68,18 @@ public function index(Request $request): View
|
||||
*/
|
||||
public function show(int $id): View
|
||||
{
|
||||
$log = ApiRequestLog::findOrFail($id);
|
||||
$log = ApiRequestLog::with(['tenant', 'user'])->findOrFail($id);
|
||||
|
||||
return view('api-logs.show', compact('log'));
|
||||
// 같은 그룹의 다른 요청들
|
||||
$groupLogs = [];
|
||||
if ($log->group_id) {
|
||||
$groupLogs = ApiRequestLog::where('group_id', $log->group_id)
|
||||
->where('id', '!=', $log->id)
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('api-logs.show', compact('log', 'groupLogs'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
* @property string|null $user_agent
|
||||
* @property int|null $user_id
|
||||
* @property int|null $tenant_id
|
||||
* @property string|null $group_id
|
||||
* @property \Carbon\Carbon $created_at
|
||||
*/
|
||||
class ApiRequestLog extends Model
|
||||
{
|
||||
protected $table = 'api_request_logs';
|
||||
protected $table = 'api_request_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
@@ -43,6 +44,7 @@ class ApiRequestLog extends Model
|
||||
'user_agent',
|
||||
'user_id',
|
||||
'tenant_id',
|
||||
'group_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -99,4 +101,57 @@ public static function pruneOldLogs(): int
|
||||
{
|
||||
return static::where('created_at', '<', now()->subDay())->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 관계
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석용 요약 텍스트 생성
|
||||
*/
|
||||
public function getAiAnalysisSummaryAttribute(): string
|
||||
{
|
||||
$responseData = json_decode($this->response_body, true);
|
||||
$errorMessage = $responseData['message'] ?? $responseData['error'] ?? '';
|
||||
|
||||
$summary = "## API 에러 분석 요청\n\n";
|
||||
$summary .= "### 요청 정보\n";
|
||||
$summary .= "- **메서드**: {$this->method}\n";
|
||||
$summary .= "- **URL**: {$this->url}\n";
|
||||
$summary .= "- **라우트**: " . ($this->route_name ?? 'N/A') . "\n";
|
||||
$summary .= "- **상태 코드**: {$this->response_status}\n";
|
||||
$summary .= "- **응답 시간**: {$this->duration_ms}ms\n";
|
||||
$summary .= "- **요청 시간**: {$this->created_at->format('Y-m-d H:i:s')}\n\n";
|
||||
|
||||
if ($this->request_body && count($this->request_body) > 0) {
|
||||
$summary .= "### 요청 바디\n```json\n";
|
||||
$summary .= json_encode($this->request_body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$summary .= "\n```\n\n";
|
||||
}
|
||||
|
||||
$summary .= "### 응답 내용\n```json\n";
|
||||
if ($responseData) {
|
||||
$summary .= json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
$summary .= $this->response_body ?? 'N/A';
|
||||
}
|
||||
$summary .= "\n```\n\n";
|
||||
|
||||
$summary .= "### 분석 요청\n";
|
||||
$summary .= "위 API 에러의 원인을 분석하고 해결 방법을 제안해주세요.\n";
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,14 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
</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">
|
||||
@@ -96,15 +104,16 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
<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">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">IP</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($logs as $log)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<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">
|
||||
{{ $log->created_at->format('H:i:s') }}
|
||||
</td>
|
||||
@@ -113,7 +122,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
{{ $log->method }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 max-w-md truncate" title="{{ $log->url }}">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 max-w-xs truncate" title="{{ $log->url }}">
|
||||
{{ $log->path }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
@@ -125,10 +134,30 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
{{ number_format($log->duration_ms) }}ms
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $log->ip_address ?? '-' }}
|
||||
@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">
|
||||
{{ $log->user_id ?? 'guest' }}
|
||||
@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)
|
||||
<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 }}">
|
||||
<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>
|
||||
</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">
|
||||
@@ -138,7 +167,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500">
|
||||
<td colspan="9" class="px-4 py-8 text-center text-gray-500">
|
||||
로그가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
</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>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
@@ -49,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<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>
|
||||
@@ -59,16 +67,98 @@
|
||||
<div class="text-sm">{{ $log->ip_address ?? '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500">User ID</div>
|
||||
<div class="text-sm">{{ $log->user_id ?? 'guest' }}</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">Tenant ID</div>
|
||||
<div class="text-sm">{{ $log->tenant_id ?? '-' }}</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 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
같은 그룹의 요청 ({{ count($groupLogs) }}개)
|
||||
<a href="{{ route('dev-tools.api-logs.index', ['group_id' => $log->group_id]) }}"
|
||||
class="text-sm font-normal text-purple-600 hover:text-purple-800 ml-2">
|
||||
전체 보기
|
||||
</a>
|
||||
</h2>
|
||||
<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.u') }}
|
||||
</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>
|
||||
@endif
|
||||
|
||||
<!-- User Agent -->
|
||||
@if($log->user_agent)
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
@@ -125,4 +215,21 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@if($log->response_status >= 400)
|
||||
<!-- AI 분석용 텍스트 (숨김) -->
|
||||
<textarea id="aiAnalysisText" class="hidden">{!! $log->ai_analysis_summary !!}</textarea>
|
||||
|
||||
<script>
|
||||
function copyAiAnalysis() {
|
||||
const text = document.getElementById('aiAnalysisText').value;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.');
|
||||
}).catch(err => {
|
||||
console.error('복사 실패:', err);
|
||||
alert('복사에 실패했습니다.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
Reference in New Issue
Block a user