generated from gc/template-java-maven
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info) - PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 - SERVICE_BADGE_VARIANTS 공통 상수 추출 - 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 - 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs) - 타이틀 아이콘 전체 페이지 통일 - 카드 테두리 디자인 통일 (border + rounded-xl) - FHD 1920x1080 최적화
631 lines
24 KiB
TypeScript
631 lines
24 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';
|
|
import Badge from '../../components/ui/Badge';
|
|
import type { BadgeVariant } from '../../components/ui/Badge';
|
|
import Button from '../../components/ui/Button';
|
|
import { SERVICE_BADGE_VARIANTS } from '../../constants/chart';
|
|
|
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|
SUCCESS: 'success',
|
|
FAIL: 'danger',
|
|
DENIED: 'warning',
|
|
EXPIRED: 'warning',
|
|
INVALID_KEY: 'danger',
|
|
ERROR: 'danger',
|
|
FAILED: 'default',
|
|
};
|
|
|
|
const METHOD_VARIANT: Record<string, BadgeVariant> = {
|
|
GET: 'success',
|
|
POST: 'info',
|
|
PUT: 'warning',
|
|
DELETE: 'danger',
|
|
};
|
|
|
|
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
const formatDate = (d: Date): string => {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
|
|
const getToday = (): string => formatDate(new Date());
|
|
const getTodayString = getToday;
|
|
|
|
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 getStatusCodeVariant = (code: number): BadgeVariant => {
|
|
if (code >= 500) return 'danger';
|
|
if (code >= 400) return 'warning';
|
|
if (code >= 200) return 'success';
|
|
return 'default';
|
|
};
|
|
|
|
const getResponseTimeClass = (ms: number): string => {
|
|
if (ms > 1000) return 'text-[var(--color-danger)]';
|
|
if (ms > 500) return 'text-[var(--color-warning)]';
|
|
return 'text-[var(--color-text-primary)]';
|
|
};
|
|
|
|
const RequestLogsPage = () => {
|
|
const navigate = useNavigate();
|
|
|
|
const [startDate, setStartDate] = useState(() => {
|
|
const d = new Date(); d.setDate(d.getDate() - 6); return formatDate(d);
|
|
});
|
|
const [endDate, setEndDate] = useState(getTodayString());
|
|
const [datePreset, setDatePreset] = useState('최근 7일');
|
|
const [serviceId, setServiceId] = useState('');
|
|
const [requestStatus, setRequestStatus] = useState('');
|
|
const [requestMethod, setRequestMethod] = useState('');
|
|
const [searchKeyword, setSearchKeyword] = useState('');
|
|
|
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
|
const [serviceBadgeMap, setServiceBadgeMap] = useState<Record<string, BadgeVariant>>({}); // key: serviceName
|
|
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);
|
|
const sorted = [...res.data].sort((a, b) => a.serviceName.localeCompare(b.serviceName));
|
|
const map: Record<string, BadgeVariant> = {};
|
|
sorted.forEach((s, idx) => {
|
|
map[s.serviceName] = SERVICE_BADGE_VARIANTS[idx % SERVICE_BADGE_VARIANTS.length];
|
|
});
|
|
setServiceBadgeMap(map);
|
|
}
|
|
} 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,
|
|
keyword: searchKeyword || 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 = () => {
|
|
const d = new Date(); d.setDate(d.getDate() - 6);
|
|
setStartDate(formatDate(d));
|
|
setEndDate(getTodayString());
|
|
setDatePreset('최근 7일');
|
|
setServiceId('');
|
|
setRequestStatus('');
|
|
setRequestMethod('');
|
|
setSearchKeyword('');
|
|
setCurrentPage(0);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchServices();
|
|
handleSearch(0);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleResetAndSearch = () => {
|
|
handleReset();
|
|
setLoading(true);
|
|
setError(null);
|
|
setCurrentPage(0);
|
|
const today = getTodayString();
|
|
const d = new Date(); d.setDate(d.getDate() - 6);
|
|
const weekAgo = formatDate(d);
|
|
const params: Record<string, string | number | undefined> = {
|
|
startDate: weekAgo,
|
|
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);
|
|
}
|
|
};
|
|
|
|
const DATE_PRESETS = [
|
|
{
|
|
label: '오늘',
|
|
fn: () => {
|
|
const t = getToday();
|
|
setStartDate(t);
|
|
setEndDate(t);
|
|
setDatePreset('오늘');
|
|
},
|
|
},
|
|
{
|
|
label: '어제',
|
|
fn: () => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - 1);
|
|
const y = formatDate(d);
|
|
setStartDate(y);
|
|
setEndDate(y);
|
|
setDatePreset('어제');
|
|
},
|
|
},
|
|
{
|
|
label: '최근 7일',
|
|
fn: () => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - 6);
|
|
setStartDate(formatDate(d));
|
|
setEndDate(getToday());
|
|
setDatePreset('최근 7일');
|
|
},
|
|
},
|
|
{
|
|
label: '이번 달',
|
|
fn: () => {
|
|
const d = new Date();
|
|
setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`);
|
|
setEndDate(getToday());
|
|
setDatePreset('이번 달');
|
|
},
|
|
},
|
|
{
|
|
label: '지난 달',
|
|
fn: () => {
|
|
const d = new Date();
|
|
d.setMonth(d.getMonth() - 1);
|
|
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
|
const e = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
|
setStartDate(s);
|
|
setEndDate(formatDate(e));
|
|
setDatePreset('지난 달');
|
|
},
|
|
},
|
|
];
|
|
|
|
const activeFilters: { label: string; onRemove: () => void }[] = [];
|
|
if (serviceId) {
|
|
const svc = services.find((s) => String(s.serviceId) === serviceId);
|
|
activeFilters.push({ label: `서비스: ${svc?.serviceName ?? serviceId}`, onRemove: () => setServiceId('') });
|
|
}
|
|
if (requestStatus) {
|
|
activeFilters.push({ label: `상태: ${requestStatus}`, onRemove: () => setRequestStatus('') });
|
|
}
|
|
if (requestMethod) {
|
|
activeFilters.push({ label: `Method: ${requestMethod}`, onRemove: () => setRequestMethod('') });
|
|
}
|
|
if (searchKeyword) {
|
|
activeFilters.push({ label: `검색: ${searchKeyword}`, onRemove: () => setSearchKeyword('') });
|
|
}
|
|
|
|
const totalElements = result?.totalElements ?? 0;
|
|
const totalPages = result?.totalPages ?? 1;
|
|
const start = totalElements === 0 ? 0 : currentPage * DEFAULT_PAGE_SIZE + 1;
|
|
const end = Math.min((currentPage + 1) * DEFAULT_PAGE_SIZE, totalElements);
|
|
|
|
const selectClassName =
|
|
'bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md px-2.5 py-1.5 text-xs text-[var(--color-text-primary)] focus:outline-none';
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* 1행: 제목 */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">요청 로그</h1>
|
|
<p className="text-sm text-[var(--color-text-secondary)]">모든 API 요청/응답 로그를 조회합니다</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2행: 필터 카드 */}
|
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 mb-4 space-y-3">
|
|
{/* 1줄: 기간 프리셋 + 날짜 입력 */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* 세그먼트 컨트롤 */}
|
|
<div className="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
|
{DATE_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.label}
|
|
type="button"
|
|
onClick={preset.fn}
|
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
datePreset === preset.label
|
|
? 'bg-[var(--color-primary)] text-white'
|
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
|
}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 구분선 */}
|
|
<div className="w-px h-6 bg-[var(--color-border)]" />
|
|
|
|
{/* 날짜 입력 */}
|
|
<div className="flex items-center gap-1.5">
|
|
<svg
|
|
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => {
|
|
setStartDate(e.target.value);
|
|
setDatePreset('');
|
|
}}
|
|
className={selectClassName}
|
|
/>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">~</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => {
|
|
setEndDate(e.target.value);
|
|
setDatePreset('');
|
|
}}
|
|
className={selectClassName}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2줄: 필터 셀렉트 + URL 검색 + 버튼 */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* 필터 아이콘 */}
|
|
<svg
|
|
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)] flex-shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z"
|
|
/>
|
|
</svg>
|
|
|
|
{/* 서비스 */}
|
|
<select value={serviceId} onChange={(e) => setServiceId(e.target.value)} className={selectClassName}>
|
|
<option value="">서비스 전체</option>
|
|
{services.map((s) => (
|
|
<option key={s.serviceId} value={s.serviceId}>
|
|
{s.serviceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* 상태 */}
|
|
<select value={requestStatus} onChange={(e) => setRequestStatus(e.target.value)} className={selectClassName}>
|
|
<option value="">상태 전체</option>
|
|
{REQUEST_STATUSES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Method */}
|
|
<select value={requestMethod} onChange={(e) => setRequestMethod(e.target.value)} className={selectClassName}>
|
|
<option value="">Method 전체</option>
|
|
{HTTP_METHODS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{m}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* URL 검색 */}
|
|
<div className="flex-1 min-w-[200px] relative">
|
|
<svg
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={searchKeyword}
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch(0)}
|
|
placeholder="URL 또는 IP로 검색..."
|
|
className="w-full pl-8 pr-3 py-1.5 bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 버튼 */}
|
|
<Button onClick={() => handleSearch(0)} variant="primary" size="sm">
|
|
검색
|
|
</Button>
|
|
<Button onClick={handleResetAndSearch} variant="outline" size="sm">
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 3줄: 활성 필터 칩 */}
|
|
{activeFilters.length > 0 && (
|
|
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-[var(--color-border)]">
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">적용된 필터:</span>
|
|
{activeFilters.map((f) => (
|
|
<span
|
|
key={f.label}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-subtle)] text-[var(--color-primary)]"
|
|
>
|
|
{f.label}
|
|
<button
|
|
type="button"
|
|
onClick={f.onRemove}
|
|
className="hover:opacity-70 transition-opacity"
|
|
aria-label="필터 해제"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 에러 메시지 */}
|
|
{error && (
|
|
<div className="mb-4 p-3 rounded-lg text-sm bg-[var(--color-danger)]/10 text-[var(--color-danger)]">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 3행: 로그 테이블 */}
|
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl overflow-hidden mb-4">
|
|
{loading ? (
|
|
<div className="text-center py-10 text-[var(--color-text-secondary)] text-sm">로딩 중...</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
|
<thead className="bg-[var(--color-bg-base)]">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
시간
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
서비스
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
Method
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
URL
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
응답코드
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
응답시간
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
응답결과
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
|
IP
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{result && result.content.length > 0 ? (
|
|
result.content.map((log) => (
|
|
<tr
|
|
key={log.logId}
|
|
onClick={() => handleRowClick(log.logId)}
|
|
className="cursor-pointer hover:bg-[var(--color-bg-base)] transition-colors"
|
|
>
|
|
{/* 시간 */}
|
|
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-secondary)]">
|
|
{formatDateTime(log.requestedAt)}
|
|
</td>
|
|
|
|
{/* 서비스 — filled Badge (rounded-md, 고정 너비) */}
|
|
<td className="px-3 py-1 whitespace-nowrap">
|
|
{log.serviceName ? (
|
|
<Badge variant={serviceBadgeMap[log.serviceName] ?? 'blue'} size="sm" className="rounded-md w-16 justify-center">
|
|
{log.serviceName}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Method — pill Badge */}
|
|
<td className="px-3 py-1 whitespace-nowrap">
|
|
<Badge variant={METHOD_VARIANT[log.requestMethod] ?? 'default'} size="sm">
|
|
{log.requestMethod}
|
|
</Badge>
|
|
</td>
|
|
|
|
{/* URL */}
|
|
<td
|
|
className="px-3 py-1 text-xs text-[var(--color-text-secondary)] truncate max-w-[340px]"
|
|
title={log.requestUrl}
|
|
>
|
|
{log.requestUrl}
|
|
</td>
|
|
|
|
{/* 응답코드 — pill Badge */}
|
|
<td className="px-3 py-1 whitespace-nowrap">
|
|
{log.responseStatus != null ? (
|
|
<Badge variant={getStatusCodeVariant(log.responseStatus)} size="sm">
|
|
{log.responseStatus}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* 응답시간 */}
|
|
<td className="px-3 py-1 whitespace-nowrap">
|
|
{log.responseTime != null ? (
|
|
<span className={`text-xs ${getResponseTimeClass(log.responseTime)}`}>
|
|
{log.responseTime}
|
|
<span className="text-[var(--color-text-tertiary)] ml-0.5">ms</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* 응답결과 — pill Badge */}
|
|
<td className="px-3 py-1 whitespace-nowrap">
|
|
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'} size="sm">
|
|
{log.requestStatus}
|
|
</Badge>
|
|
</td>
|
|
|
|
{/* IP */}
|
|
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-tertiary)]">
|
|
{log.requestIp}
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={8} className="px-3 py-10 text-center text-sm text-[var(--color-text-tertiary)]">
|
|
검색 결과가 없습니다
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 4행: 페이지네이션 (테이블 카드 내부 하단) */}
|
|
{result && result.totalElements > 0 && (
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
|
총{' '}
|
|
<span className="text-[var(--color-text-primary)] font-semibold">{totalElements}</span>건 중{' '}
|
|
<span className="text-[var(--color-text-primary)] font-semibold">
|
|
{start}-{end}
|
|
</span>
|
|
건 표시
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handlePrev}
|
|
disabled={currentPage === 0}
|
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
|
>
|
|
← 이전
|
|
</button>
|
|
<span className="text-xs">
|
|
<span className="text-[var(--color-text-primary)] font-bold">{currentPage + 1}</span>
|
|
<span className="text-[var(--color-text-tertiary)]"> / </span>
|
|
<span className="text-[var(--color-text-secondary)]">{totalPages}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleNext}
|
|
disabled={!result || currentPage >= result.totalPages - 1}
|
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
|
>
|
|
다음 →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RequestLogsPage;
|