You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
433 lines
18 KiB
TypeScript
433 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Clock,
|
|
Globe,
|
|
Activity,
|
|
Filter,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Calendar,
|
|
BarChart3,
|
|
RefreshCw
|
|
} from 'lucide-react';
|
|
|
|
interface RequestLog {
|
|
id: number;
|
|
request_id: string;
|
|
endpoint: string;
|
|
method: string;
|
|
path: string;
|
|
query_params?: any;
|
|
request_body?: any;
|
|
status_code: number;
|
|
response_size?: number;
|
|
user_agent?: string;
|
|
client_ip?: string;
|
|
response_time_ms?: number;
|
|
created_at?: string;
|
|
}
|
|
|
|
interface RequestLogStats {
|
|
total_requests: number;
|
|
success_requests: number;
|
|
client_error_requests: number;
|
|
server_error_requests: number;
|
|
success_rate: number;
|
|
requests_by_method: { [key: string]: number };
|
|
requests_by_status_code: { [key: string]: number };
|
|
requests_by_endpoint: { [key: string]: number };
|
|
average_response_time_ms: number;
|
|
start_date: string;
|
|
end_date: string;
|
|
}
|
|
|
|
const RequestLogViewer: React.FC = () => {
|
|
const [logs, setLogs] = useState<RequestLog[]>([]);
|
|
const [stats, setStats] = useState<RequestLogStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [showStats, setShowStats] = useState(false);
|
|
const [expandedLog, setExpandedLog] = useState<number | null>(null);
|
|
|
|
// Filter state
|
|
const [filters, setFilters] = useState({
|
|
start_date: '', // Remove default date filter for debugging
|
|
end_date: '', // Remove default date filter for debugging
|
|
method: '',
|
|
status_code: '',
|
|
endpoint: '',
|
|
page: 1,
|
|
page_size: 50
|
|
});
|
|
|
|
function getDefaultDateRange() {
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - 7); // Last 7 days for requests
|
|
return {
|
|
start_date: startDate.toISOString().split('T')[0],
|
|
end_date: endDate.toISOString().split('T')[0]
|
|
};
|
|
}
|
|
|
|
const fetchLogs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value) {
|
|
// Add timezone info for date parameters
|
|
if (key === 'start_date') {
|
|
params.append(key, value.toString() + 'T00:00:00Z');
|
|
} else if (key === 'end_date') {
|
|
params.append(key, value.toString() + 'T23:59:59Z');
|
|
} else {
|
|
params.append(key, value.toString());
|
|
}
|
|
}
|
|
});
|
|
|
|
const response = await fetch(`/api/v1/admin/requests/logs?${params}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setLogs(data.items || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching request logs:', error);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (filters.start_date) params.append('start_date', filters.start_date + 'T00:00:00Z');
|
|
if (filters.end_date) params.append('end_date', filters.end_date + 'T23:59:59Z');
|
|
|
|
const response = await fetch(`/api/v1/admin/requests/stats?${params}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setStats(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching request stats:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
fetchStats();
|
|
}, [filters]);
|
|
|
|
const getStatusCodeColor = (statusCode: number): string => {
|
|
if (statusCode >= 200 && statusCode < 300) return 'text-green-600 bg-green-50';
|
|
if (statusCode >= 300 && statusCode < 400) return 'text-blue-600 bg-blue-50';
|
|
if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600 bg-yellow-50';
|
|
if (statusCode >= 500) return 'text-red-600 bg-red-50';
|
|
return 'text-gray-600 bg-gray-50';
|
|
};
|
|
|
|
const getMethodColor = (method: string): string => {
|
|
const colors: Record<string, string> = {
|
|
'GET': 'text-green-600 bg-green-50',
|
|
'POST': 'text-blue-600 bg-blue-50',
|
|
'PUT': 'text-yellow-600 bg-yellow-50',
|
|
'DELETE': 'text-red-600 bg-red-50',
|
|
'PATCH': 'text-purple-600 bg-purple-50'
|
|
};
|
|
return colors[method] || 'text-gray-600 bg-gray-50';
|
|
};
|
|
|
|
const formatResponseTime = (ms?: number): string => {
|
|
if (!ms) return 'N/A';
|
|
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
};
|
|
|
|
const formatSize = (bytes?: number): string => {
|
|
if (!bytes) return 'N/A';
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
|
<Activity className="w-8 h-8 text-blue-600" />
|
|
요청 로그
|
|
</h1>
|
|
<p className="text-gray-600 mt-2">
|
|
API 요청 내역 및 통계를 확인할 수 있습니다
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowStats(!showStats)}
|
|
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
|
|
showStats
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
|
} border transition-colors`}
|
|
>
|
|
<BarChart3 className="w-4 h-4" />
|
|
통계
|
|
</button>
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
|
|
showFilters
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
|
} border transition-colors`}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
필터
|
|
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => { fetchLogs(); fetchStats(); }}
|
|
className="px-4 py-2 bg-white text-gray-600 hover:bg-gray-50 border rounded-lg flex items-center gap-2 transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistics */}
|
|
{showStats && stats && (
|
|
<div className="mb-6 grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white p-4 rounded-lg border">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">총 요청</p>
|
|
<p className="text-2xl font-bold">{stats.total_requests.toLocaleString()}</p>
|
|
</div>
|
|
<Globe className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg border">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">성공률</p>
|
|
<p className="text-2xl font-bold text-green-600">{stats.success_rate.toFixed(1)}%</p>
|
|
</div>
|
|
<Activity className="w-8 h-8 text-green-600" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg border">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">평균 응답시간</p>
|
|
<p className="text-2xl font-bold">{formatResponseTime(stats.average_response_time_ms)}</p>
|
|
</div>
|
|
<Clock className="w-8 h-8 text-orange-600" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg border">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">서버 에러</p>
|
|
<p className="text-2xl font-bold text-red-600">{stats.server_error_requests.toLocaleString()}</p>
|
|
</div>
|
|
<Activity className="w-8 h-8 text-red-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
{showFilters && (
|
|
<div className="mb-6 bg-white p-4 rounded-lg border">
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">시작 날짜</label>
|
|
<input
|
|
type="date"
|
|
value={filters.start_date}
|
|
onChange={(e) => setFilters({ ...filters, start_date: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">종료 날짜</label>
|
|
<input
|
|
type="date"
|
|
value={filters.end_date}
|
|
onChange={(e) => setFilters({ ...filters, end_date: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP 메서드</label>
|
|
<select
|
|
value={filters.method}
|
|
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">전체</option>
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="DELETE">DELETE</option>
|
|
<option value="PATCH">PATCH</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">상태 코드</label>
|
|
<input
|
|
type="number"
|
|
placeholder="예: 200, 404, 500"
|
|
value={filters.status_code}
|
|
onChange={(e) => setFilters({ ...filters, status_code: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">엔드포인트</label>
|
|
<input
|
|
type="text"
|
|
placeholder="예: /api/v1/financial/*"
|
|
value={filters.endpoint}
|
|
onChange={(e) => setFilters({ ...filters, endpoint: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Logs Table */}
|
|
<div className="bg-white rounded-lg border overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-8 text-center">
|
|
<RefreshCw className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-4" />
|
|
<p className="text-gray-600">요청 로그를 불러오는 중...</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
시간
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
메서드
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
엔드포인트
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
상태
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
응답시간
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
크기
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
IP
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
상세
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{logs.map((log) => (
|
|
<React.Fragment key={log.id}>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{log.created_at ? new Date(log.created_at).toLocaleString('ko-KR') : 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getMethodColor(log.method)}`}>
|
|
{log.method}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title={log.path}>
|
|
{log.endpoint}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(log.status_code)}`}>
|
|
{log.status_code}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{formatResponseTime(log.response_time_ms)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{formatSize(log.response_size)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{log.client_ip || 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
<button
|
|
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
|
className="text-blue-600 hover:text-blue-800"
|
|
>
|
|
{expandedLog === log.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{expandedLog === log.id && (
|
|
<tr>
|
|
<td colSpan={8} className="px-6 py-4 bg-gray-50">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 mb-2">요청 정보</h4>
|
|
<div className="bg-white p-3 rounded border text-sm">
|
|
<p><span className="font-medium">Request ID:</span> {log.request_id}</p>
|
|
<p><span className="font-medium">Full Path:</span> {log.path}</p>
|
|
<p><span className="font-medium">User Agent:</span> {log.user_agent || 'N/A'}</p>
|
|
{log.query_params && Object.keys(log.query_params).length > 0 && (
|
|
<div>
|
|
<span className="font-medium">Query Parameters:</span>
|
|
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto">
|
|
{JSON.stringify(log.query_params, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{log.request_body && (
|
|
<div>
|
|
<span className="font-medium">Request Body:</span>
|
|
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto">
|
|
{JSON.stringify(log.request_body, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{logs.length === 0 && (
|
|
<div className="p-8 text-center">
|
|
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
<p className="text-gray-500">조건에 맞는 요청 로그가 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RequestLogViewer; |