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.

689 lines
30 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import {
Clock,
Globe,
Activity,
Filter,
ChevronDown,
ChevronUp,
Calendar,
BarChart3,
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
AlertCircle,
Trash2
} from 'lucide-react';
interface LogEntry {
id: number;
request_id: string;
endpoint: string;
method: string;
path: string;
query_params?: any;
request_body?: any;
headers?: any;
status_code: number;
response_size?: number;
user_agent?: string;
client_ip?: string;
response_time_ms?: number;
created_at?: string;
// Error specific fields
error_type?: string;
error_message?: string;
error_detail?: any;
stack_trace?: string;
is_error?: boolean;
}
interface LogStats {
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 UnifiedLogViewer: React.FC = () => {
const [isClient, setIsClient] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [stats, setStats] = useState<LogStats | null>(null);
const [loading, setLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [showStats, setShowStats] = useState(true);
const [expandedLog, setExpandedLog] = useState<number | null>(null);
const [logType, setLogType] = useState<'all' | 'errors' | 'success' | 'data'>('data');
// Filter state
const [filters, setFilters] = useState({
start_date: '',
end_date: '',
method: '',
status_code: '',
endpoint: '',
page: 1,
page_size: 50
});
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) {
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());
}
}
});
// Filter by log type
if (logType === 'errors') {
params.append('min_status_code', '500');
} else if (logType === 'success') {
params.append('status_code', '200');
}
let allLogs: LogEntry[] = [];
if (logType === 'data') {
// Fetch financial and price data requests separately and combine
try {
const financialParams = new URLSearchParams(params);
financialParams.append('endpoint', '/api/v1/financial/*');
const priceParams = new URLSearchParams(params);
priceParams.append('endpoint', '/api/v1/price/*');
const [financialData, priceData] = await Promise.all([
adminApi.getRequestLogs(Object.fromEntries(financialParams)),
adminApi.getRequestLogs(Object.fromEntries(priceParams))
]);
// Combine and sort by created_at
allLogs = [...(financialData.items || []), ...(priceData.items || [])].sort((a: LogEntry, b: LogEntry) =>
new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime()
);
} catch (error) {
console.error('Error fetching data logs:', error);
}
} else {
try {
const data = await adminApi.getRequestLogs(Object.fromEntries(params));
allLogs = data.items || [];
} catch (error) {
console.error('Error fetching logs:', error);
}
}
// Fetch error details for 500 errors
const enrichedLogs = await Promise.all(
allLogs.map(async (log: LogEntry) => {
if (log.status_code >= 500) {
try {
const errorData = await adminApi.getErrorByRequestId(log.request_id);
return {
...log,
is_error: true,
error_type: errorData.error_type,
error_message: errorData.error_message,
error_detail: errorData.error_detail,
stack_trace: errorData.stack_trace
};
} catch (error) {
console.error('Error fetching error details:', error);
}
}
return { ...log, is_error: log.status_code >= 400 };
})
);
setLogs(enrichedLogs);
} catch (error) {
console.error('Error fetching logs:', error);
}
setLoading(false);
};
const fetchStats = async () => {
try {
const params: Record<string, string> = {};
if (filters.start_date) params.start_date = filters.start_date + 'T00:00:00Z';
if (filters.end_date) params.end_date = filters.end_date + 'T23:59:59Z';
const data = await adminApi.getRequestStats(params);
setStats(data);
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const clearAllLogs = async () => {
if (!window.confirm('정말로 모든 로그를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
return;
}
try {
setLoading(true);
// Clear both request logs and error logs
const [requestResult, errorResult] = await Promise.all([
adminApi.deleteRequestLogs({ confirm: 'true' }),
adminApi.deleteErrorLogs({ confirm: 'true' })
]);
alert(`성공적으로 삭제되었습니다:\n- 요청 로그: ${requestResult.deleted_count}\n- 에러 로그: ${errorResult.deleted_count}`);
// Refresh data
await fetchLogs();
await fetchStats();
} catch (error) {
console.error('Error clearing logs:', error);
alert('로그 삭제 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
if (isClient) {
fetchLogs();
fetchStats();
}
}, [filters, logType, isClient]);
const getStatusIcon = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) return <CheckCircle className="w-4 h-4 text-green-600" />;
if (statusCode >= 400 && statusCode < 500) return <AlertCircle className="w-4 h-4 text-yellow-600" />;
if (statusCode >= 500) return <XCircle className="w-4 h-4 text-red-600" />;
return <Activity className="w-4 h-4 text-gray-600" />;
};
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`;
};
const formatDateTime = (dateString?: string): string => {
if (!dateString || !isClient) return 'N/A';
try {
return new Date(dateString).toLocaleString('ko-KR');
} catch (error) {
return dateString;
}
};
if (!isClient) {
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="p-8 text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600"> ...</p>
</div>
</div>
</div>
);
}
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={clearAllLogs}
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 border border-red-600 rounded-lg flex items-center gap-2 transition-colors"
>
<Trash2 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>
{/* Log Type Selector */}
<div className="mb-6 flex gap-2 flex-wrap">
<button
onClick={() => setLogType('data')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'data'
? 'bg-purple-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<BarChart3 className="w-4 h-4" />
</button>
<button
onClick={() => setLogType('all')}
className={`px-4 py-2 rounded-lg ${
logType === 'all'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
</button>
<button
onClick={() => setLogType('errors')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'errors'
? 'bg-red-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<AlertTriangle className="w-4 h-4" />
</button>
<button
onClick={() => setLogType('success')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'success'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<CheckCircle className="w-4 h-4" />
</button>
</div>
{/* Statistics */}
{showStats && (
<div className="mb-6 grid grid-cols-1 md:grid-cols-5 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 text-gray-900">
{stats?.total_requests !== undefined ? stats.total_requests.toLocaleString() : 'Loading...'}
</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_requests !== undefined ? stats.success_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<CheckCircle 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 text-yellow-600">
{stats?.client_error_requests !== undefined ? stats.client_error_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-yellow-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 !== undefined ? stats.server_error_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<XCircle className="w-8 h-8 text-red-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-gray-900">
{stats?.average_response_time_ms !== undefined ? formatResponseTime(stats.average_response_time_ms) : 'Loading...'}
</p>
</div>
<Clock className="w-8 h-8 text-orange-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 ${log.is_error ? 'bg-red-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusIcon(log.status_code)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(log.created_at)}
</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-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">
{/* Request Information */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2"> </h4>
<div className="bg-white p-3 rounded border text-sm text-gray-900">
<p className="text-gray-900"><span className="font-medium text-gray-700">Request ID:</span> {log.request_id}</p>
<p className="text-gray-900"><span className="font-medium text-gray-700">Full Path:</span> {log.path}</p>
<p className="text-gray-900"><span className="font-medium text-gray-700">User Agent:</span> {log.user_agent || 'N/A'}</p>
{/* Data Source Info for successful requests */}
{log.status_code >= 200 && log.status_code < 300 && log.headers?.['X-Data-Source'] && (
<div className="mt-2 p-2 bg-blue-50 border border-blue-200 rounded">
<p className="text-blue-900">
<span className="font-medium text-blue-700"> :</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{log.headers['X-Data-Source'] === 'database-cache' ? 'bg-green-100 text-green-800' :
log.headers['X-Data-Source'] === 'yfinance-fresh' ? 'bg-yellow-100 text-yellow-800' :
log.headers['X-Data-Source'] === 'yfinance-partial' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'}">
{log.headers['X-Data-Source'] === 'database-cache' ? '🗄️ DB 캐시' :
log.headers['X-Data-Source'] === 'yfinance-fresh' ? '🔄 Yahoo Finance (신규)' :
log.headers['X-Data-Source'] === 'yfinance-partial' ? '📊 Yahoo Finance (부분)' :
log.headers['X-Data-Source']}
</span>
</p>
</div>
)}
{log.headers && Object.keys(log.headers).length > 0 && (
<div className="mt-2">
<span className="font-medium text-gray-700">Headers:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.headers, null, 2)}
</pre>
</div>
)}
{log.query_params && Object.keys(log.query_params).length > 0 && (
<div className="mt-2">
<span className="font-medium text-gray-700">Query Parameters:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.query_params, null, 2)}
</pre>
</div>
)}
{log.request_body && (
<div className="mt-2">
<span className="font-medium text-gray-700">Request Body:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.request_body, null, 2)}
</pre>
</div>
)}
</div>
</div>
{/* Error Information */}
{log.is_error && (log.error_message || log.status_code >= 400) && (
<div>
<h4 className="text-sm font-medium text-red-900 mb-2"> </h4>
<div className="bg-red-50 p-3 rounded border border-red-200 text-sm text-red-900">
<p className="text-red-900"><span className="font-medium text-red-700">Status Code:</span> {log.status_code}</p>
{log.error_type && (
<p className="text-red-900"><span className="font-medium text-red-700">Error Type:</span> {log.error_type}</p>
)}
{log.error_message && (
<p className="text-red-900"><span className="font-medium text-red-700">Error Message:</span> {log.error_message}</p>
)}
{/* Error Response Body - show raw response from error_detail */}
{log.error_detail && (
<div className="mt-2">
<span className="font-medium text-red-700"> :</span>
<pre className="mt-1 bg-white p-2 rounded text-xs overflow-x-auto text-red-800 max-h-48 overflow-y-auto">
{typeof log.error_detail === 'string' ? log.error_detail : JSON.stringify(log.error_detail, null, 2)}
</pre>
</div>
)}
{log.stack_trace && (
<div className="mt-2">
<span className="font-medium text-red-700">Stack Trace:</span>
<pre className="mt-1 bg-white p-2 rounded text-xs overflow-x-auto max-h-64 overflow-y-auto text-gray-800">
{log.stack_trace}
</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 UnifiedLogViewer;