API 로그 페이지 개선: 테넌트/사용자 표시, 그룹핑, AI 분석 복사

- 테넌트/사용자 컬럼 추가 (관계 eager loading)
- 그룹 ID로 연관 API 호출 필터링 및 상세 페이지에서 그룹 목록 표시
- 상태 400 이상일 때 AI 분석용 복사 버튼 추가
- Tenant 모델 네임스페이스 수정 (Tenants\Tenant)
This commit is contained in:
2025-12-15 16:33:58 +09:00
parent f5e2068557
commit e7beefd594
4 changed files with 230 additions and 18 deletions

View File

@@ -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'));
}
/**

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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