generated from gc/template-java-maven
백엔드:
- GatewayController (ANY /gateway/{serviceCode}/**)
- GatewayService (API Key prefix 매칭 + AES 복호화, 권한 확인, WebClient 프록시)
- AsyncConfig + RequestLogService (@Async 비동기 로깅)
- RequestLogController (검색 + 상세 API, JPA Specification)
- request_url을 gateway 경로로 통일 저장
- tenant_id 로그 기록 추가
- ErrorCode 6개 (GW001-GW006)
프론트엔드:
- RequestLogsPage (검색 폼 + 결과 테이블 + 페이지네이션)
- RequestLogDetailPage (요청/응답 상세)
- 날짜 검색 LocalDate 변환 수정
Closes #9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { RequestLog, PageResponse } from '../../types/monitoring';
|
|
import type { ServiceInfo } from '../../types/service';
|
|
import { searchLogs } from '../../services/monitoringService';
|
|
import { getServices } from '../../services/serviceService';
|
|
|
|
const METHOD_COLOR: Record<string, string> = {
|
|
GET: 'bg-green-100 text-green-800',
|
|
POST: 'bg-blue-100 text-blue-800',
|
|
PUT: 'bg-orange-100 text-orange-800',
|
|
DELETE: 'bg-red-100 text-red-800',
|
|
};
|
|
|
|
const STATUS_BADGE: Record<string, string> = {
|
|
SUCCESS: 'bg-green-100 text-green-800',
|
|
DENIED: 'bg-red-100 text-red-800',
|
|
EXPIRED: 'bg-orange-100 text-orange-800',
|
|
INVALID_KEY: 'bg-red-100 text-red-800',
|
|
FAILED: 'bg-gray-100 text-gray-800',
|
|
};
|
|
|
|
const REQUEST_STATUSES = ['SUCCESS', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'FAILED'];
|
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
const getTodayString = (): string => {
|
|
const d = new Date();
|
|
const year = d.getFullYear();
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const formatDateTime = (dateStr: string): string => {
|
|
const d = new Date(dateStr);
|
|
const year = d.getFullYear();
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
};
|
|
|
|
const RequestLogsPage = () => {
|
|
const navigate = useNavigate();
|
|
|
|
const [startDate, setStartDate] = useState(getTodayString());
|
|
const [endDate, setEndDate] = useState(getTodayString());
|
|
const [serviceId, setServiceId] = useState('');
|
|
const [requestStatus, setRequestStatus] = useState('');
|
|
const [requestMethod, setRequestMethod] = useState('');
|
|
const [requestIp, setRequestIp] = useState('');
|
|
|
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
|
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
|
|
const fetchServices = async () => {
|
|
try {
|
|
const res = await getServices();
|
|
if (res.success && res.data) {
|
|
setServices(res.data);
|
|
}
|
|
} catch {
|
|
// 서비스 목록 로딩 실패는 무시
|
|
}
|
|
};
|
|
|
|
const handleSearch = async (page: number) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setCurrentPage(page);
|
|
|
|
try {
|
|
const params: Record<string, string | number | undefined> = {
|
|
startDate: startDate || undefined,
|
|
endDate: endDate || undefined,
|
|
serviceId: serviceId ? Number(serviceId) : undefined,
|
|
requestStatus: requestStatus || undefined,
|
|
requestMethod: requestMethod || undefined,
|
|
requestIp: requestIp || undefined,
|
|
page,
|
|
size: DEFAULT_PAGE_SIZE,
|
|
};
|
|
const res = await searchLogs(params);
|
|
if (res.success && res.data) {
|
|
setResult(res.data);
|
|
} else {
|
|
setError(res.message || '로그 조회에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
setError('로그 조회에 실패했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setStartDate(getTodayString());
|
|
setEndDate(getTodayString());
|
|
setServiceId('');
|
|
setRequestStatus('');
|
|
setRequestMethod('');
|
|
setRequestIp('');
|
|
setCurrentPage(0);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchServices();
|
|
handleSearch(0);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleResetAndSearch = () => {
|
|
handleReset();
|
|
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
|
|
setLoading(true);
|
|
setError(null);
|
|
setCurrentPage(0);
|
|
const today = getTodayString();
|
|
const params: Record<string, string | number | undefined> = {
|
|
startDate: today,
|
|
endDate: today,
|
|
page: 0,
|
|
size: DEFAULT_PAGE_SIZE,
|
|
};
|
|
searchLogs(params)
|
|
.then((res) => {
|
|
if (res.success && res.data) {
|
|
setResult(res.data);
|
|
} else {
|
|
setError(res.message || '로그 조회에 실패했습니다.');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setError('로그 조회에 실패했습니다.');
|
|
})
|
|
.finally(() => {
|
|
setLoading(false);
|
|
});
|
|
};
|
|
|
|
const handleRowClick = (logId: number) => {
|
|
navigate(`/monitoring/request-logs/${logId}`);
|
|
};
|
|
|
|
const handlePrev = () => {
|
|
if (currentPage > 0) {
|
|
handleSearch(currentPage - 1);
|
|
}
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (result && currentPage < result.totalPages - 1) {
|
|
handleSearch(currentPage + 1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
|
|
|
|
{/* Search Form */}
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">기간</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
<span className="text-gray-500">~</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">서비스</label>
|
|
<select
|
|
value={serviceId}
|
|
onChange={(e) => setServiceId(e.target.value)}
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="">전체</option>
|
|
{services.map((s) => (
|
|
<option key={s.serviceId} value={s.serviceId}>
|
|
{s.serviceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
|
<select
|
|
value={requestStatus}
|
|
onChange={(e) => setRequestStatus(e.target.value)}
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="">전체</option>
|
|
{REQUEST_STATUSES.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP Method</label>
|
|
<select
|
|
value={requestMethod}
|
|
onChange={(e) => setRequestMethod(e.target.value)}
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="">전체</option>
|
|
{HTTP_METHODS.map((m) => (
|
|
<option key={m} value={m}>{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">IP</label>
|
|
<input
|
|
type="text"
|
|
value={requestIp}
|
|
onChange={(e) => setRequestIp(e.target.value)}
|
|
placeholder="IP 주소"
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<button
|
|
onClick={() => handleSearch(0)}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
검색
|
|
</button>
|
|
<button
|
|
onClick={handleResetAndSearch}
|
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
|
|
{/* Results Table */}
|
|
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
|
|
{loading ? (
|
|
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
|
) : (
|
|
<table className="w-full divide-y divide-gray-200 text-sm">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">시간</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">서비스</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Method</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Status Code</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">응답시간(ms)</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">상태</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500">IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{result && result.content.length > 0 ? (
|
|
result.content.map((log) => (
|
|
<tr
|
|
key={log.logId}
|
|
onClick={() => handleRowClick(log.logId)}
|
|
className="cursor-pointer hover:bg-gray-50"
|
|
>
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
{formatDateTime(log.requestedAt)}
|
|
</td>
|
|
<td className="px-4 py-3">{log.serviceName || '-'}</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
|
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{log.requestMethod}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 truncate max-w-[250px]" title={log.requestUrl}>
|
|
{log.requestUrl}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{log.responseStatus != null ? log.responseStatus : '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{log.responseTime != null ? log.responseTime : '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{log.requestStatus}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs">{log.requestIp}</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
|
검색 결과가 없습니다
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{result && result.totalElements > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-600">
|
|
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handlePrev}
|
|
disabled={currentPage === 0}
|
|
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
이전
|
|
</button>
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={!result || currentPage >= result.totalPages - 1}
|
|
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RequestLogsPage;
|