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.
572 lines
22 KiB
TypeScript
572 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { adminApi } from '@/lib/api';
|
|
import {
|
|
AlertTriangle,
|
|
Filter,
|
|
RefreshCw,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Eye,
|
|
Trash2,
|
|
Calendar
|
|
} from 'lucide-react';
|
|
|
|
interface ErrorLog {
|
|
id: number;
|
|
request_id: string;
|
|
endpoint: string;
|
|
method: string;
|
|
path: string;
|
|
query_params?: any;
|
|
request_body?: any;
|
|
error_type: string;
|
|
error_message: string;
|
|
error_detail?: any;
|
|
status_code: number;
|
|
stack_trace?: string;
|
|
user_agent?: string;
|
|
client_ip?: string;
|
|
response_time_ms?: number;
|
|
is_resolved: boolean;
|
|
resolved_at?: string;
|
|
resolution_notes?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ErrorStats {
|
|
total_errors: number;
|
|
resolved_errors: number;
|
|
unresolved_errors: number;
|
|
resolution_rate: number;
|
|
errors_by_type: Record<string, number>;
|
|
errors_by_status_code: Record<string, number>;
|
|
errors_by_endpoint: Record<string, number>;
|
|
average_response_time_ms: number;
|
|
}
|
|
|
|
interface ErrorLogListResponse {
|
|
items: ErrorLog[];
|
|
total: number;
|
|
page: number;
|
|
page_size: number;
|
|
total_pages: number;
|
|
}
|
|
|
|
const ErrorLogViewer: React.FC = () => {
|
|
const [errorLogs, setErrorLogs] = useState<ErrorLog[]>([]);
|
|
const [stats, setStats] = useState<ErrorStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedLog, setSelectedLog] = useState<ErrorLog | null>(null);
|
|
const [showDetails, setShowDetails] = useState(false);
|
|
// Calculate default date range (last 30 days)
|
|
const getDefaultDateRange = () => {
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - 30);
|
|
|
|
return {
|
|
start_date: startDate.toISOString().split('T')[0],
|
|
end_date: endDate.toISOString().split('T')[0]
|
|
};
|
|
};
|
|
|
|
const [filters, setFilters] = useState({
|
|
page: 1,
|
|
page_size: 50,
|
|
error_type: '',
|
|
status_code: '',
|
|
endpoint: '',
|
|
is_resolved: '',
|
|
...getDefaultDateRange()
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchErrorLogs();
|
|
fetchStats();
|
|
}, [filters]);
|
|
|
|
const fetchErrorLogs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value !== '' && value !== null && value !== undefined) {
|
|
params[key] = value.toString();
|
|
}
|
|
});
|
|
|
|
const data: ErrorLogListResponse = await adminApi.getErrorLogs(params);
|
|
setErrorLogs(data.items);
|
|
} catch (error) {
|
|
console.error('Error fetching error logs:', error);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (filters.start_date) params.start_date = filters.start_date;
|
|
if (filters.end_date) params.end_date = filters.end_date;
|
|
|
|
const data: ErrorStats = await adminApi.getErrorStats(params);
|
|
setStats(data);
|
|
} catch (error) {
|
|
console.error('Error fetching stats:', error);
|
|
}
|
|
};
|
|
|
|
const markAsResolved = async (logId: number, resolved: boolean, notes?: string) => {
|
|
try {
|
|
const updatedLog = await adminApi.resolveError(logId, resolved, notes);
|
|
await fetchErrorLogs();
|
|
await fetchStats();
|
|
if (selectedLog && selectedLog.id === logId) {
|
|
setSelectedLog(updatedLog);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating error log:', error);
|
|
}
|
|
};
|
|
|
|
const deleteOldLogs = async (daysOld: number, onlyResolved: boolean) => {
|
|
try {
|
|
const result = await adminApi.deleteErrorLogs({
|
|
days_old: daysOld.toString(),
|
|
only_resolved: onlyResolved.toString(),
|
|
});
|
|
await fetchErrorLogs();
|
|
await fetchStats();
|
|
alert(result.message);
|
|
} catch (error) {
|
|
console.error('Error deleting old logs:', error);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleString();
|
|
};
|
|
|
|
const getStatusCodeColor = (statusCode: number) => {
|
|
if (statusCode >= 500) return 'text-red-600 bg-red-100';
|
|
if (statusCode >= 400) return 'text-orange-600 bg-orange-100';
|
|
return 'text-gray-600 bg-gray-100';
|
|
};
|
|
|
|
const getErrorTypeColor = (errorType: string) => {
|
|
const colors: Record<string, string> = {
|
|
'VALIDATION_ERROR': 'text-blue-600 bg-blue-100',
|
|
'DATA_NOT_FOUND': 'text-yellow-600 bg-yellow-100',
|
|
'PARSING_ERROR': 'text-purple-600 bg-purple-100',
|
|
'SEC_API_ERROR': 'text-red-600 bg-red-100',
|
|
'DATABASE_ERROR': 'text-red-800 bg-red-200',
|
|
'INTERNAL_SERVER_ERROR': 'text-red-800 bg-red-200'
|
|
};
|
|
return colors[errorType] || 'text-gray-600 bg-gray-100';
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">에러 로그 관리</h1>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => fetchErrorLogs()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Refresh
|
|
</button>
|
|
<button
|
|
onClick={() => deleteOldLogs(30, true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Delete Old
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistics */}
|
|
{stats && (
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-red-600">Total Errors</p>
|
|
<p className="text-2xl font-bold text-red-700">{stats.total_errors}</p>
|
|
</div>
|
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-green-600">Resolved</p>
|
|
<p className="text-2xl font-bold text-green-700">{stats.resolved_errors}</p>
|
|
</div>
|
|
<CheckCircle className="w-8 h-8 text-green-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-orange-600">Unresolved</p>
|
|
<p className="text-2xl font-bold text-orange-700">{stats.unresolved_errors}</p>
|
|
</div>
|
|
<XCircle className="w-8 h-8 text-orange-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-blue-600">Resolution Rate</p>
|
|
<p className="text-2xl font-bold text-blue-700">{stats.resolution_rate.toFixed(1)}%</p>
|
|
</div>
|
|
<Clock className="w-8 h-8 text-blue-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Filter className="w-5 h-5 text-gray-500" />
|
|
<h3 className="text-lg font-semibold">Filters</h3>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Error Type
|
|
</label>
|
|
<select
|
|
value={filters.error_type}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, error_type: e.target.value, page: 1 }))}
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="VALIDATION_ERROR">Validation Error</option>
|
|
<option value="DATA_NOT_FOUND">Data Not Found</option>
|
|
<option value="PARSING_ERROR">Parsing Error</option>
|
|
<option value="SEC_API_ERROR">SEC API Error</option>
|
|
<option value="DATABASE_ERROR">Database Error</option>
|
|
<option value="INTERNAL_SERVER_ERROR">Internal Server Error</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Status Code
|
|
</label>
|
|
<select
|
|
value={filters.status_code}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, status_code: e.target.value, page: 1 }))}
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All Codes</option>
|
|
<option value="400">400</option>
|
|
<option value="404">404</option>
|
|
<option value="422">422</option>
|
|
<option value="500">500</option>
|
|
<option value="503">503</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Endpoint
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={filters.endpoint}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, endpoint: e.target.value, page: 1 }))}
|
|
placeholder="e.g. /financial/data"
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Status
|
|
</label>
|
|
<select
|
|
value={filters.is_resolved}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, is_resolved: e.target.value, page: 1 }))}
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All</option>
|
|
<option value="false">Unresolved</option>
|
|
<option value="true">Resolved</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Start Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={filters.start_date}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, start_date: e.target.value, page: 1 }))}
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
End Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={filters.end_date}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, end_date: e.target.value, page: 1 }))}
|
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Logs Table */}
|
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
<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">
|
|
Time
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Endpoint
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Error Type
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Message
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Resolution
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
|
Loading error logs...
|
|
</td>
|
|
</tr>
|
|
) : errorLogs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
|
No error logs found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
errorLogs.map((log) => (
|
|
<tr key={log.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{formatDate(log.created_at)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
<div>
|
|
<span className="font-medium">{log.method}</span>
|
|
<br />
|
|
<span className="text-gray-500 text-xs">{log.endpoint}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getErrorTypeColor(log.error_type)}`}>
|
|
{log.error_type}
|
|
</span>
|
|
</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 text-sm text-gray-900 max-w-xs truncate">
|
|
{log.error_message}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{log.is_resolved ? (
|
|
<span className="flex items-center gap-1 text-green-600">
|
|
<CheckCircle className="w-4 h-4" />
|
|
Resolved
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-red-600">
|
|
<XCircle className="w-4 h-4" />
|
|
Open
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedLog(log);
|
|
setShowDetails(true);
|
|
}}
|
|
className="text-blue-600 hover:text-blue-900"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => markAsResolved(log.id, !log.is_resolved)}
|
|
className={log.is_resolved ? "text-orange-600 hover:text-orange-900" : "text-green-600 hover:text-green-900"}
|
|
>
|
|
{log.is_resolved ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex justify-between items-center">
|
|
<div className="text-sm text-gray-700">
|
|
Showing {errorLogs.length} errors
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setFilters(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
|
|
disabled={filters.page <= 1}
|
|
className="px-3 py-2 bg-gray-300 text-gray-700 rounded disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="px-3 py-2 bg-blue-500 text-white rounded">
|
|
{filters.page}
|
|
</span>
|
|
<button
|
|
onClick={() => setFilters(prev => ({ ...prev, page: prev.page + 1 }))}
|
|
disabled={errorLogs.length < filters.page_size}
|
|
className="px-3 py-2 bg-gray-300 text-gray-700 rounded disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Details Modal */}
|
|
{showDetails && selectedLog && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto">
|
|
<div className="p-6">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h2 className="text-xl font-bold">Error Details</h2>
|
|
<button
|
|
onClick={() => setShowDetails(false)}
|
|
className="text-gray-500 hover:text-gray-700"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Request ID</label>
|
|
<p className="text-sm text-gray-900 font-mono">{selectedLog.request_id}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Timestamp</label>
|
|
<p className="text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Method & Endpoint</label>
|
|
<p className="text-sm text-gray-900 font-mono">
|
|
{selectedLog.method} {selectedLog.endpoint}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Status Code</label>
|
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(selectedLog.status_code)}`}>
|
|
{selectedLog.status_code}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Error Message</label>
|
|
<p className="text-sm text-gray-900 bg-gray-50 p-3 rounded">{selectedLog.error_message}</p>
|
|
</div>
|
|
|
|
{selectedLog.error_detail && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Error Details</label>
|
|
<pre className="text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-auto">
|
|
{JSON.stringify(selectedLog.error_detail, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{selectedLog.request_body && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Request Body</label>
|
|
<pre className="text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-auto">
|
|
{JSON.stringify(selectedLog.request_body, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{selectedLog.stack_trace && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700">Stack Trace</label>
|
|
<pre className="text-sm text-gray-900 bg-red-50 p-3 rounded overflow-auto text-red-800">
|
|
{selectedLog.stack_trace}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-4 pt-4 border-t">
|
|
<button
|
|
onClick={() => markAsResolved(selectedLog.id, !selectedLog.is_resolved)}
|
|
className={`px-4 py-2 rounded text-white ${
|
|
selectedLog.is_resolved
|
|
? 'bg-orange-500 hover:bg-orange-600'
|
|
: 'bg-green-500 hover:bg-green-600'
|
|
}`}
|
|
>
|
|
{selectedLog.is_resolved ? 'Mark as Unresolved' : 'Mark as Resolved'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDetails(false)}
|
|
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ErrorLogViewer; |