snp-connection-monitoring/frontend/src/pages/monitoring/RequestLogsPage.tsx
HYOJIN 88e25abe14 feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)
- 디자인 시스템 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 최적화
2026-04-17 14:45:27 +09:00

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;